Compare commits

Author SHA1 Message Date
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
ddadminandClaude Fable 5 453950a639 docs(specs): итог тикета #203 внесен в раздел 9 спеки программы лаб
- Зачем:
  - раздел 9 отсылал к открытому тикету #203; решение принято, указатель
    должен вести на итог, а не на вопрос.
- Что:
  - фраза «докуда ведет карта #182» заменена итогом: карта закрыта приемкой
    спеки, исполнение ведет карта #206.
- Проверка:
  - вычитка раздела 9 файла docs/specs/2026-09-01-labs-program.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 09:32:27 -04:00
ddmitry c26b4530f3 Merge pull request 'docs(specs): спека программы учебных лаб' (#205) from 202-labs-program-spec into main
Reviewed-on: #205
2026-09-01 16:13:49 +03:00
ddadmin eed5b4b4e6 docs(specs): словарь программы лаб очеловечен по ревью владельца
- Зачем:
  - термины спеки перейдут в задания, а «хребет», «ветки» и «точка
    прицепа» с калькированными заголовками сюжетов смущали бы студентов.
- Что:
  - хребет → основная программа, шаги → ступени, ветки → факультативы,
    «точка прицепа» заменена формулой «открывается после ступени N»;
  - заголовки разделов и сюжетов переписаны из позиции студента
    («Почему выручка не сходится», «Куда попадают битые строки»);
  - переносы выровнены по ~80 колонок по всему файлу.
- Проверка:
  - текст без учета пробелов не изменился на шаге переносов (SHA-256);
    решения, состав и порядок программы не менялись.
2026-09-01 09:07:40 -04:00
ddadmin 4fd872f296 docs(specs): спека программы учебных лаб — хребет и ветки
- Зачем:
  - тикет #202 карты #182: собрать из инвентаризации #201 программу лаб
    целиком — состав, порядок, формы и судьбу идей-кандидатов.
- Что:
  - создан docs/specs/2026-09-01-labs-program.md: хребет из семи
    обязательных шагов и пять тематических разделов необязательных лаб;
  - узаконены границы вычитания по потокам и поименные резолюции идей
    #143, #145, #148, #149, #186, #187, #188;
  - записаны отклоненные варианты оси прогрессии и границы спеки (#203).
- Проверка:
  - вычитка владельцем (спека принята), слепое ревью фактов против
    инвентаризации и доков — все находки закрыты.
2026-09-01 08:45:43 -04:00
ddmitry 22bf09af9d Merge pull request 'docs(research): инвентаризация сюжетов лаб по слоям стенда' (#204) from research/labs-inventory into main 2026-08-31 18:39:20 +03:00
ddadminandClaude Fable 5 502d705ca1 docs(research): инвентаризация сюжетов лаб по слоям стенда
- Зачем:
  - спеке программы лаб (#202) нужен полный веер сюжетов вместо семи
    случайно накопленных идей; резолюция тикета #201 карты #182.
- Что:
  - заметка docs/research/2026-08-31-labs-inventory.md: 32 сюжета по
    слоям STG, ODS, DDS, DM, BI и поперек слоев, три формы заданий,
    разбор гипотезы «вычитание + DQ» с таблицей по потокам.
- Проверка:
  - фактура (пути, имена таблиц, номера тикетов) сверена с репозиторием
    при вычитке; кода изменение не касается.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:38:54 -04:00
ddmitry d22156a7da Merge pull request 'docs(labs): ТЗ необязательной лабы «свой дашборд в Superset»' (#199) from 197-superset-lab into main
Reviewed-on: #199
2026-08-31 11:07:53 +03:00
ddadminandClaude Fable 5 6174536cc1 docs(specs): BI-лаба узаконена в исключении раздела 10
Зачем: раздел 10 выносил все лабы v2 в отдельную работу учебного пути,
делая исключение только для домашек DM, — необязательная лаба Superset
из #197 формально в него не попадала.

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

Проверка: вычитка; решение владельца репозитория из обсуждения #197.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 04:05:43 -04:00
ddadminandClaude Fable 5 989ff71495 docs(labs): ТЗ необязательной лабы «свой дашборд в Superset»
Зачем: владеть хотя бы одним BI-инструментом на базовом уровне — полезный
навык дата-инженера, но стенд в первую очередь про ClickHouse, поэтому
лаба необязательная. Обещание «отдельная домашка после этапа 8» в доке DM
висело без адреса.

Что: ТЗ docs/labs/superset-dashboard.md — пять датасетов поверх публичных
представлений заданий 1-5, свой дашборд отдельно от стендового, проверка
глазами без homework_check; шестая строка и разведение «пяти заданий по
витринам» в docs/labs/README.md; абзац о сроке жизни объектов менти в
docs/architecture/superset.md; ссылка на лабу из доки DM; правило слага в
AGENTS.md расширено до «предмет задания».

Проверка: кода и DDL нет; слепое ревью текста (9 находок закрыто, 2
дополнительные с перепроверки) и финальная вычитка.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 03:55:40 -04:00
ddmitry cd82911906 Merge pull request 'feat(superset): датасеты и дашборд «Бизнес-сюжеты» (этап 8)' (#198) from 196-superset-dashboard into main
Reviewed-on: #198
2026-08-31 10:26:41 +03:00
ddadminandClaude Fable 5 3ffce6387e feat(superset): датасеты и дашборд «Бизнес-сюжеты» деревом импорта
- Зачем:
  - этап 8 (#196): три сюжета спеки — выручка, сверка, посетители /
    известные — должны приезжать в Superset при каждой инициализации
    стенда, без ручной сборки в интерфейсе.
- Что:
  - дерево infra/superset/import/ дополнено датасетами трех витрин dm,
    тремя графиками и дашбордом «Бизнес-сюжеты»; uuid розданы рукой,
    русские строки читаемы, metadata.yaml сокращен до версии формата.
  - написан справочник docs/architecture/superset.md: устройство зоны,
    дерево импорта, маршрут свежего экспорта, ловушки импорта.
  - в docs/architecture/dm.md добавлена ссылка на справочник зоны.
- Проверка:
  - docker compose run --rm superset-init — импорт начисто, затем три
    графика отдают данные через API (54/48/10 строк).
  - прогон рецепта «чистая установка» из справочника: сброс базы superset
    в postgres-metadata, импорт, активы на месте.
  - make lint и make check-services зеленые (7/7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:42:01 -04:00
ddmitry c999153acb Merge pull request 'feat(generator): откалиброваны доли заказной стороны' (#195) from 194-calibration into main
Reviewed-on: #195
2026-08-30 23:49:13 +03:00
ddmitry c3a105a0f8 Merge pull request 'feat(generator): эталонный мир зафиксирован равным стартовому' (#193) from 192-stage7-tail into main
Reviewed-on: #193
2026-08-30 23:48:10 +03:00
ddadminandClaude Fable 5 9b1826f5ff feat(generator): откалиброваны доли заказной стороны
- Зачем:
  - семь мест обещали калибровку черновых долей «при пересборке
    эталонного мира (этап 7)», а пересборки не будет (#192); числа
    сверены замером и зафиксированы (#194, хвост этапа 7 — #9).
- Что:
  - замер на каноническом мире (28 дней, без транспорта): все доли в
    пределах ориентиров; пометки «черновое» сняты, наблюдаемые доли
    вписаны в комментарии world.py, fate.md и запись «Что проверено»;
  - потеря purchase: наблюдаемых 1,78% при броске 3% — вычеты штатные
    (пары склейки, приоритет отмены), принято решением владельца;
    ориентир класса B в мастер-спеке различает бросок и наблюдаемую долю;
  - веса мира и опись не тронуты: правки world.py — только комментарии.
- Проверка:
  - cd generator && make lint && make typecheck && make test (437 passed);
  - git diff по data/world-inventory.json пуст; grep «черновое|калибровка»
    по generator/src и docs/architecture/orders пуст.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:42:32 -04:00
ddadminandClaude Fable 5 8b7987c5c2 docs(specs): снято обещание CI-генерации на amd64 и arm64
- Зачем:
  - CI дублировал бы сторожем то, что уже гарантировано устройством:
    дисциплина целочисленной случайности снимает межархитектурные
    расхождения (решение владельца 2026-08-30, хвост этапа 7 — #9).
- Что:
  - резолюция вписана в раздел 5 спеки генератора;
  - строки этапа 7 в обеих спеках приведены к резолюции.
- Проверка:
  - grep "CI гоняет" docs/specs/ пуст; чтение раздела 5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:11:34 -04:00
ddadminandClaude Fable 5 c1b61d81b9 feat(generator): эталонный мир зафиксирован равным стартовому
- Зачем:
  - у дней 8-13 описи не нашлось читателя: обещание эталона в 14 дней
    снято решением владельца 2026-08-30 (#192, этап 7 — #9).
- Что:
  - резолюция перенесена в обе спеки, докстринг описи и словарь CONTEXT.md;
  - добавлен тест: проигранный через CLI день совпадает с хешем описи;
  - сняты замеры: пересборка описи 20,5 с, снимок 460,7 МБ, make test
    176,5 с — записаны в карту проверок и таблицу бюджетов.
- Проверка:
  - cd generator && make lint && make typecheck && make test (437 passed);
  - git diff по data/world-inventory.json пуст.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:53:52 -04:00
ddmitry f93c924b33 Merge pull request 'feat(generator): добавлено опоздание заказов в слепках' (#191) from 185-late-orders into main
Reviewed-on: #191
2026-08-29 10:06:31 +03:00
ddadmin c68a451698 docs(orders): восстановлено описание окна опоздания
- Зачем:
  - при перевёрстке абзаца пропало правило отсутствия заказа до дня приезда.
- Что:
  - восстановлен диапазон скрытых слепков d…d+δ−1.
  - исправлена пунктуация и прояснено обнуление задержки назначенного заказа.
- Проверка:
  - git diff --check.
  - make config-test.
2026-08-29 02:03:24 -04:00
ddadmin 8db1c58c12 feat(generator): добавлено опоздание заказов в слепках
- Зачем:
  - менти должен увидеть, как запоздавшая выгрузка меняет открытое окно, не подделывая аудит источника.
- Что:
  - заказам назначена задержка D, D+1 или D+2 последним броском подпотока ORDERS.
  - назначенный планом заказ приезжает сразу, а Woven передаёт эту гарантию именованным полем.
  - обновлены документы, проверки границ механики и эталонные хеши слепков.
- Проверка:
  - make lint, make typecheck и make test в generator: 436 тестов прошли.
  - make config-test: конфигурация прошла проверку.
  - world_live_day и следующий etl_pipeline перевели заказ 20260608-0001 из awaiting_order в match.
2026-08-28 17:30:55 -04:00
ddmitry 58e944aa87 Merge pull request 'feat(generator): потеря и дубль purchase (B, D) и счетчики описи' (#189) from 184-beacons into main
Reviewed-on: #189
2026-08-28 21:58:23 +03:00
ddadminandClaude Fable 5 c28f724706 feat(generator): потеря и дубль purchase (B, D) и счетчики описи
- Зачем:
  - трекер стенда был полным, и правило «деньги — по бэкенду» держалось
    на слове; классы B и D делают недосчет и пересчет трекера
    наблюдаемыми (этап 6, #184).
- Что:
  - подпоток BEACONS: точечная потеря purchase (~3%) и сюжетный дубль
    (~2%) швом после _purchases, с гарантией моста идентичностей и
    условием допуска дубля по паузе до следующего визита;
  - опись считает пять классов по приоритету спеки; фильтр
    classes_vs_inventory расширен, нулевой класс читается нулем;
  - fate.md, seeds.md, dm.md и orders/README.md приведены к имени
    BEACONS и строгому правилу допуска.
- Проверка:
  - make lint, make typecheck, make test (433 passed) и make inventory
    из generator/ — хеши слепков не изменились;
  - на стенде: make smoke, make check-clickhouse, make check-services
    зеленые; в dq_summary classes_vs_inventory и sessions_vs_reference
    pass, витрина сверки показывает lost_event и duplicate_event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 13:13:47 -04:00
ddmitry c49f39f01f Merge pull request 'docs(generator): заморозка спеки этапа 2 и ADR 0015-0017' (#181) from spec-freeze into main
Reviewed-on: #181
2026-08-28 16:27:37 +03:00
ddadmin dcf8db1305 Merge remote-tracking branch 'origin/main' into spec-freeze 2026-08-28 08:16:11 -04:00
ddadminandClaude Fable 5 6f18e9989c docs(generator): заморожена спека этапа 2, решения — в ADR 0015-0017
- Зачем:
  - спека разрослась до 1058 строк, а журнал решений в разделе 9 читался
    только целиком; агентам и людям нужен дешевый адресный заход к
    основаниям решений генератора.
- Что:
  - заведены ADR 0015 (пояс счетчика), 0016 (паспорт куки) и 0017 (номер
    заказа); четыре адресные ссылки на «раздел 9» перешиты на них.
  - раздел 9 пересобран в 11 тематических подразделов с датами и тикетами
    у каждого решения и закрыт пометкой о заморозке; устаревшие
    промежуточные замеры свернуты до указателей на актуальные.
  - строка статуса в шапке отмечает закрытие спеки как документа этапа 2.
- Проверка:
  - wc -l docs/specs/2026-08-01-generator.md (951, было 1058);
    rg -n 'раздел[а-яё]* 9|разделы 8 и 9' docs -g '*.md' — 18 попаданий,
    все классифицированы; разделы 1-8 нетронуты (сверка хешем).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 08:16:11 -04:00
ddmitry 9cc3e5ae3b Merge pull request 'docs(generator): путеводитель по генератору и живой проход make demo' (#179) from 48-generator-guide into main
Reviewed-on: #179
2026-08-28 15:03:35 +03:00
ddadmin 74061aa499 Merge branch 'main' into 48-generator-guide 2026-08-28 06:06:19 -04:00
ddmitry 2a13f46a6d Merge pull request 'fix(compose): устранена гонка сборки общих образов' (#178) from fix/compose-shared-image-build into main
Reviewed-on: #178
2026-08-28 13:05:17 +03:00
Dmitry Dementiev cd4f9c55f5 docs(compose): объяснение общей сборки сокращено
- Зачем:
  - ADR и комментарии повторяли устройство Compose и говорили о сборке безлично.
- Что:
  - в Compose оставлен один указатель на причину решения.
  - ADR хранит правило общей сборки и объясняет гонку Buildx.
- Проверка:
  - make config-test.
  - git diff --check.
2026-08-28 13:03:47 +03:00
ddadminandClaude Fable 5 0c466c082e docs(generator): путеводитель по генератору и живой проход make demo
- Зачем:
  - спека отвечает «почему», а жанра «как пройти устройство по коду»
    не было (#48); черновик в generator/README.md ждал выверки.
- Что:
  - новый набор docs/architecture/generator/: индекс с целевой картиной
    и mermaid-схемой плюс шесть поддокументов по темам тикета.
  - демо-модуль и цель make demo: канонический D0, все стадии цепочки
    на одном экране; тест-сторож проверяет стадии без прибитых чисел.
  - раздел «Как это работает» в generator/README.md сжат до ссылки
    на путеводитель, черновая врезка снята.
- Проверка:
  - из generator/: make lint, make typecheck, make test (430 passed),
    make demo; два слепых ревью и узкая перепроверка — находки закрыты.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 06:00:36 -04:00
Dmitry Dementiev 1f203c974d fix(compose): устранена гонка сборки общих образов
- Зачем:
  - make up иногда падал, когда несколько служб параллельно экспортировали один тег образа.
- Что:
  - сборка образов Airflow и Superset закреплена за службами инициализации.
  - зависимые службы только используют готовый образ, решение описано в ADR 0001.
- Проверка:
  - make config-test.
  - docker compose --progress plain build superset superset-init airflow-scheduler airflow-init.
  - два запуска make up прошли экспорт трёх уникальных тегов без image already exists.
2026-08-28 12:53:33 +03:00
ddmitry 5946175efa Merge pull request 'fix(grafana): исправлена загрузка плагина при сборке' (#177) from fix/grafana-plugin-download into main
Reviewed-on: #177
2026-08-28 12:42:27 +03:00
ddmitry cdfa31f51d Merge pull request 'fix(superset): стабилизирована проверка рабочего контура' (#176) from 172-superset-cold-check into main
Reviewed-on: #176
2026-08-28 12:41:25 +03:00
ddadmin cc836fc3c5 fix(superset): стабилизирована проверка рабочего контура
- Зачем:
  - холодный запуск второго приложения Superset давал ложный красный результат исправного стенда.
- Что:
  - тяжёлые `create_app()` и `superset test-db` заменены проверкой сохранённого подключения через REST API.
  - добавлены проверка схемы `dm`, безопасная диагностика и краткая запись приёмки с замерами.
- Проверка:
  - `make config-test`.
  - `make smoke`: 21/21 за 25 секунд.
  - три `make check-services` после перезапуска Superset: 7/7 за 71, 67 и 70 секунд; финальный прогон — 7/7 за 70 секунд.
  - контрольный неверный UUID: 5/1, красной стала проверка метаданных.
2026-08-28 12:38:54 +03:00
Dmitry Dementiev e422e6d1fa fix(grafana): исправлена загрузка плагина при сборке
- Зачем:
  - make up падал с HTTP 451 при загрузке плагина из каталога Grafana.
- Что:
  - закреплённый выпуск плагина загружается из официального GitHub Releases.
  - версия плагина вынесена в один аргумент сборки, причина выбора описана в справочнике мониторинга.
- Проверка:
  - docker build --progress=plain -t clickstream-grafana:local infra/grafana.
  - docker run --rm --entrypoint grafana clickstream-grafana:local cli --pluginsDir /usr/share/grafana/plugins plugins ls.
  - make config-test.
2026-08-28 12:31:48 +03:00
ddmitry 1fae4e589f Merge pull request 'feat(airflow): пульт мира объясняет ось дней в форме и журнале' (#175) from 111-world-date into main
Reviewed-on: #175
2026-08-28 12:16:38 +03:00
ddadminandClaude Fable 5 17995d4c5e fix(airflow): формулировки пульта переписаны живым русским
- Зачем:
  - владелец на ревью PR #175 отметил кальку в подсказках; читать их
    будет менти, и язык здесь — часть продукта.
- Что:
  - переписаны подсказка формы days, докстринги работников, строки
    журнала remember_played, абзац README и пункт в etl.md; смысл
    не менялся.
- Проверка:
  - make lint, make config-test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 05:14:51 -04:00
ddadminandClaude Fable 5 c2c4401a16 feat(airflow): пульт мира объясняет ось дней в форме и журнале
- Зачем:
  - оператор принимал служебную logical_date Airflow за дату мира (#111),
    а документацию читают реже, чем форму запуска.
- Что:
  - у параметра days появилась подсказка description_md, а в докстринги
    работников добавлена фраза: день определяет world_position, служебные
    поля Airflow к оси мира отношения не имеют.
  - remember_played пишет в журнал сыгранные дни и новую позицию.
  - README дополнен абзацем про служебные поля за «Advanced Options»;
    из etl.md сняты устаревшие стыки на закрытые #7 и #2.
- Проверка:
  - make lint, make config-test; ручной прогон world_next_day зелёный,
    позиция 9 -> 10, в журнале «сыграны дни 9–9, позиция теперь 10».

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 05:05:44 -04:00
ddmitry 33aef73d0e Merge pull request 'feat(dm): учебные задания витрин DM и даг самопроверки' (#174) from 159-dm-labs into main
Reviewed-on: #174
2026-08-28 09:53:45 +03:00
ddadminandClaude Fable 5 f33d75113d feat(dm): добавлены учебные задания витрин и даг самопроверки
- Зачем:
  - менти строит витрины DM сам, от DDL по правилу зерна до задачи в
    конвейере; стенду нужны ТЗ с образцом и обратная связь по каждой
    витрине (#159, резолюция #135).
- Что:
  - docs/labs/: README и пять ТЗ — events_enriched_v, top_pages_daily,
    session_overview, dq_errors_daily, utm_effectiveness; каждое ТЗ
    называет урок, состав колонок, форму по зерну и самопроверку.
  - dags/homework_check.py: ручной даг, задача на витрину, красная без
    витрины или при расхождении; проверки — шесть файлов sql/dq/ с
    инвариантами против DDS и ODS, без золотых констант; в dq_summary
    не пишет.
  - docs: dm.md называет homework_check и снимает «Остается нарезке»,
    §7 и §10 мастер-спеки согласованы с лабами, dds/session.md указывает
    на ТЗ, AGENTS.md пополнен правилом имен docs/labs/.
- Проверка:
  - make lint; make smoke 21/21, make check-clickhouse 10/10,
    make check-services 7/7.
  - на свежем стенде все пять задач homework_check красные и называют
    ТЗ; черновик каждой витрины по тексту ТЗ зеленит свою задачу,
    после сноса — снова красная; строк от самопроверки в dq_summary нет.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:44:15 -04:00
ddmitry 35d43125b1 Merge pull request 'feat(dq): добавлен контроль качества конвейера' (#173) from 158-dq-pipeline into main
Reviewed-on: #173
2026-08-27 18:42:44 +03:00
ddadmin 54686651df fix(dq): устранено переполнение памяти проверок
- Зачем:
  - параллельный DQ выгружал деловые ключи в Python и исчерпывал 640 МиБ планировщика.
- Что:
  - четыре сверки агрегируют дневные итоги и ограниченную диагностику в ClickHouse.
  - коробка планировщика поднята до 1,5 ГиБ, локальные пределы задач удалены.
  - измерения и граница между SQL и Python записаны в документации.
- Проверка:
  - make lint, make config-test, make smoke, make check-clickhouse, make check-services.
  - полный etl_pipeline, красный и восстановительный прогоны dq_check.
2026-08-27 11:25:19 -04:00
ddadmin 26b2e5fea1 feat(dq): добавлен контроль качества конвейера
- Зачем:
  - конвейеру нужна публикуемая сводка текущего качества с красным завершением при нарушении договоров.
- Что:
  - добавлены DDL, публичное представление и пять сверок на естественном деловом зерне.
  - даг dq_check встроен после DM и публикует полные дневные партиции до именованных утверждений.
  - параллелизм DDS, DM и DQ приведён к измеренному лимиту памяти планировщика.
- Проверка:
  - make lint; make config-test; make rebuild-storage.
  - make smoke; make check-clickhouse; make check-services.
  - три семейства проверок покрашены искусственными расхождениями и восстановлены повторным полным прогоном.
2026-08-27 07:55:28 -04:00
ddmitry 4e60aafc31 Merge pull request 'feat(etl): добавлен дневной конвейер трансформаций' (#171) from 156-etl-pipeline into main
Reviewed-on: #171
2026-08-27 13:13:25 +03:00
ddadmin cec11edaf3 chore(repo): влиты изменения задачи 155
- Зачем:
  - ветка ETL должна учитывать новую витрину `purchase_vs_orders` из `main`.
- Что:
  - `origin/main` влит без переписывания истории опубликованного PR;
  - `dm_transform` теперь собирает третью витрину внутри общего конвейера.
- Проверка:
  - `make config-test`, `make lint`;
  - `generator`: `make lint`, `make typecheck`, `make test` — 429 тестов;
  - `make rebuild-storage`, `make smoke`, `make check-clickhouse`, `make check-services`;
  - импорт DAG без ошибок, `purchase_vs_orders` заполнена.
2026-08-27 12:23:02 +03:00
ddmitry 2a6162237c Merge pull request 'feat(dm): добавлена сверка покупок с заказами' (#170) from 155-purchase-vs-orders into main
Reviewed-on: #170
2026-08-27 11:55:50 +03:00
ddadmin 20071c565f feat(dm): добавлена сверка покупок с заказами
- Зачем:
  - показать расхождения трекера и бэкенда на зерне заказа
  - сохранить отложенные покупки наблюдаемыми до следующего слепка
- Что:
  - добавлены таблицы, представление и пересборка purchase_vs_orders
  - сумма покупки нормализована в Decimal на границе ODS
  - обновлены договоры и указатели на механику пересборки
- Проверка:
  - `make config-test`, `make lint`
  - `cd generator && make docs lint typecheck test`
  - `make rebuild-storage`, явные `dds_transform` и `dm_transform`
  - `make smoke`, `make check-clickhouse`, `make check-services`
2026-08-27 04:21:15 -04:00
ddadmin 61e662c19c refactor(etl): упрощена проверка готовности Kafka
- Зачем:
  - верхние смещения можно получить тем же административным клиентом без одноразового Consumer и фиктивной группы.
- Что:
  - чтение хвоста переведено на AdminClient.list_offsets с OffsetSpec.latest.
  - назначение system.one названо рядом с запросом, а документация и запись проверок сокращены и переставлены по смыслу.
- Проверка:
  - make config-test; make lint.
  - живые проверки готового и отсутствующего разделов Kafka; полный etl_pipeline.
2026-08-27 11:17:43 +03:00
ddadmin 12a5c109b2 feat(etl): добавлен дневной конвейер трансформаций
Зачем:
- Один запуск должен одинаково работать из жизненного цикла, вручную после разгона и повторно после отказа.

Что:
- Добавлена последовательная сцепка DDS и DM с ожиданием каждого слоя.
- DDS дожидается смещений Kafka и очереди доставки ClickHouse за общий срок.
- ETL встроен в создание мира и живой день; документация приведена к новому графу.

Проверка:
- make config-test; make lint.
- make smoke: 21/21; make check-clickhouse: 10/10; make check-services: 7/7.
- make rebuild-storage, живой и пакетный дни, искусственный отказ и ручное восстановление ETL.
2026-08-27 02:43:29 +03:00
ddmitry fb00bbe4c3 Merge pull request 'feat(dm): добавлены витрины выручки и трафика' (#169) from 154-dm-marts into main
Reviewed-on: #169
2026-08-26 22:23:38 +03:00
ddadmin 741820789f refactor(dm): уточнены ограничения DAG и пояснения SQL
- Зачем:
  - Ревью выявило неясные причины ограничений и защитных условий в учебном образце.
- Что:
  - Спекулятивный общий потолок задач заменен адресным лимитом размноженной замены с точным описанием OOM.
  - Объяснены нулевое значение непарного LEFT JOIN и опасность пустого DDS при замене партиций.
  - Добавлена ссылка на разбор области пересборки, а общий штамп про Context7 удален.
- Проверка:
  - make lint, make config-test, make smoke; успешный запуск dm_transform.
2026-08-26 14:56:33 -04:00
ddadmin da0477f0f1 feat(dm): добавлены витрины выручки и трафика
Зачем:
- Показать два ритма пересборки агрегатов на готовых витринах DM.

Что:
- Добавлены физические модели и публичные договоры revenue_daily и daily_traffic.
- Добавлен dm_transform с оконной заменой выручки и полным пересчетом трафика.
- Отрицательный опыт локального соединения через публичные _v зафиксирован; выбран явный GLOBAL LEFT JOIN.
- Замены партиций сериализованы после подтвержденного OOM при трех одновременных задачах.
- Обновлены архитектурные документы и мастер-спека.

Проверка:
- make lint, make config-test, make smoke, make check-clickhouse, make check-services.
- Чистая сборка world_recreate, dds_transform и dm_transform; меры сверены с прямыми агрегатами.
2026-08-26 13:55:26 -04:00
ddmitry c9bcacd63b Merge pull request 'feat(dds): добавлена карта идентичностей' (#168) from 153-dds-identity-map into main
Reviewed-on: #168
2026-08-26 19:52:52 +03:00
ddadmin 39ff0b5bb4 feat(dds): добавлена карта идентичностей
- Зачем:
  - реализованы требования issue #153 к мосту между кукой и пользователем.
- Что:
  - добавлены физическая карта, точное представление и полный пересчёт через GLOBAL JOIN.
  - задача identity_map поставлена после сборки заказов в dds_transform.
  - устройство и живые проверки карты записаны в документации хранилища.
- Проверка:
  - make lint, make smoke, make check-clickhouse, make check-services.
  - три запуска dds_transform: 4770 физических строк и 1590 уникальных пар в _v.
2026-08-26 12:30:40 -04:00
ddmitry cdd44c469f Merge pull request 'feat(ods): версией заказа стала дата слепка, окно DDS — из данных' (#167) from 165-ods-order-version-snapshot-date into main
Reviewed-on: #167
2026-08-26 18:50:37 +03:00
ddadmin a9cead2974 docs(orders): документация перестроена вокруг текущей модели
- Зачем:
  - описание вело нового читателя через историю исправления вместо действующего
    контракта слоёв.
- Что:
  - прямо описаны версия заказа, границы пакетного приёма и расчёт окна DDS.
  - ответственность ODS, DDS и конвейера разделена без ссылок на прежние
    ошибки как на основной довод.
  - исторический вывод исследования помечен до устаревшего абзаца, а ссылки
    ведут к отдельным решениям и контрактам.
- Проверка:
  - make lint.
  - make config-test.
2026-08-26 18:47:22 +03:00
ddadminandClaude Opus 5 a82c75705d feat(ods): версией заказа стала дата слепка, окно DDS — из данных
- Зачем:
  - ярлык snapshot_date выжившей строки доставался жребием, а объём
    пересборки DDS держался на константе окна с двумя несверяемыми копиями
    (ADR 0014, развилка #163).
- Что:
  - ods.order_rep переведена на ReplacingMergeTree(snapshot_date): выживает
    строка позднейшего слепка, ярлык стал правдивым.
  - order_scope.sql выводит окно из дней последнего слепка; ORDER_WINDOW_DAYS
    и параметр window_days сняты со стенда, мёртвая защита краем источника
    убрана.
  - документы и комментарии SQL приведены к ADR 0014: роль ODS, условие
    «одна дата — один слепок», объём прогона и слепые пятна конвейера.
- Проверка:
  - make lint; make rebuild-storage, затем make smoke, make check-clickhouse,
    make check-services — зелёные.
  - после двух слепков snapshot_date всех 1917 строк ods.order_v равен
    последнему слепку заказа, расхождений ноль; деловые поля построчно
    совпали с эталоном argMax по updated_at из сырья.
  - dds_transform без K: первый прогон 06-01…06-07, после нового дня мира
    06-02…06-08, повтор прогона содержимого не изменил.

Closes #165

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:28:14 +03:00
ddmitry 650efacc3f Merge pull request 'feat(dds): модель сессии и нарезка визитов окнами' (#166) from 152-dds-session-model into main
Reviewed-on: #166
2026-08-26 16:19:06 +03:00
ddadminandClaude Opus 5 176cf5520d feat(dds): добавлена модель сессии и нарезка визитов окнами
Зачем:
- Дать слою расчётную сущность, которой в потоке нет: стенд режет визиты сам,
  а VisitID генератора остаётся эталоном самопроверки.
- Отделить прожитый день от живого — иначе визит, начатый в 23:50, замёрз бы
  обрезанным навсегда: пересобирать замёрзший день слой не станет.

Что:
- Добавлены dds.session_rep/_dist, двойник-донор dds.session_stage_rep/_dist и
  публичный договор dds.session_v.
- Нарезка стоит на двух окнах: первое ставит шов там, где пауза длиннее
  тридцати минут, второе считает нарастающий итог швов — номер визита. Границу
  суток держит разбиение окна, отдельной проверки ей не нужно.
- Порядок в окнах полный, по метке времени и event_id: сортировок две, и при
  равных метках они разошлись бы, разорвав сессию надвое.
- Объём прогона считается от первой дыры в слое по последний прожитый день;
  прожитый день выводится из позиции мира и первого дня событий. Собирать
  нечего — группа задач пропускается, а не зеленеет впустую.
- В dds_transform появилась группа session рядом с заказом; зависимости по
  данным между ними нет.
- Потолок max_active_tasks: размноженная замена без него выносит планировщика
  за отведённые ему 640 МиБ.
- parallel_distributed_insert_select у сессий не назначается вслух — ключ
  шардирования у источника и цели один по контракту ко-локации.
- Записано, что позиция отвечает за «прожит», а не за «доехал»: сторож хвоста
  потока приезжает отдельным тикетом.

Проверка:
- make lint
- make smoke (21 проверка), make check-clickhouse (10), make check-services
- Стартовый мир, пересобранный с нуля: 79367 сессий за восемь дней;
  расхождение с uniqExact(VisitID) по паре «кука × день» — ноль; все 401185
  событий ровно в одной сессии; session_id уникальны.
- Ни одной паузы длиннее тридцати минут внутри сессии и ни одной пары соседних
  визитов куки, разделённой меньшим промежутком.
- Повтор после DROP PARTITION ALL: те же session_id и то же содержимое строк.
- Снесённая руками партиция 2026-06-04: следующий прогон взял хвост от неё,
  дни до дыры не тронуты.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 07:56:44 -04:00
ddadminandClaude Fable 5 c361c36e3d docs(adr): версия заказа в ODS пересмотрена на дату слепка
- Зачем:
  - лотерея ярлыков доставки при ничьей по updated_at делала слой
    недетерминированным и держала окно K двумя копиями без сверки (#163).
- Что:
  - добавлен ADR 0014: версия ods.order — snapshot_date, с ответом доводу
    ADR 0010, границами допущения и веером отклонённых вариантов.
  - в ADR 0010 помечен пересмотр колонки версии.
- Проверка:
  - реализация и замеры — тикет #165; решение кодом пока не применено.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 06:38:46 -04:00
ddmitry 9ab43e79cc Merge pull request 'feat(dds): модель заказа и пересборка окна заменой партиций' (#164) from 151-dds-order-model into main
Reviewed-on: #164
2026-08-26 12:36:09 +03:00
ddadminandClaude Opus 5 535b03783a docs(dds): убраны повторы объяснений в комментариях заказа
Зачем:
- Полный разбор урока про идемпотентность стоял дважды, предупреждение про
  окно K — трижды; внимание менти конечно, и каждый пересказ списывается
  с этого счёта.

Что:
- Докстринг dds_transform сокращён до одной фразы с указателями: полный разбор
  остался в шапке DDL, механика подмены — в order_replace.sql.
- Комментарий про ORDER_WINDOW_DAYS в даге сведён к указателю на compose,
  где число объявлено и где записан риск расхождения.
- Прорежен повтор одного риторического приёма «это не X» — оставлен там,
  где различие и есть суть.

Проверка:
- make lint, make config-test
- Прогон dds_transform зелёный, слой сходится с ods.order_v: 1917 заказов,
  8 дней.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 05:32:03 -04:00
ddadminandClaude Opus 5 1e8b83ffbb feat(dds): добавлена модель заказа и пересборка окна заменой партиций
Зачем:
- Дать слою первую физическую сущность и показать идемпотентность, устроенную
  иначе, чем в приёме: заменой партиции, а не колонкой версии.
- Родить механику дневной пересборки на кластере — образец для сессий и витрин.

Что:
- Добавлены dds.order_rep/_dist, двойник-донор dds.order_stage_rep/_dist и
  публичный договор dds.order_v с разбором позиций в три массива.
- Добавлены запросы объёма прогона, сборки в двойник и замены партиции по дню.
- Добавлен даг dds_transform: группа задач order, замена — размноженной задачей.
- Окно изменяемости K пришло на сторону стенда якорем compose; владелец числа
  остаётся у генератора.
- Объём считается от позднейшего дня заказа и первого недостающего дня: ярлыки
  доставки ODS для этого непригодны, а максимум собранного дня прячет дыры.
- Записано, чего конвейер о себе не знает, и адресовано контуру DQ.

Проверка:
- make lint
- make smoke, make check-clickhouse, make check-services
- Прогон по стартовому миру: 1917 заказов, счёт равен ods.order_v, каждый заказ
  на шарде своего cityHash64(order_id), сумма позиций равна items_total.
- Повтор без нового слепка: содержимое то же, _load_id новый; после слепка
  пересобрано окно, день за окном не тронут.
- Снесённая руками партиция 2026-06-01 восстановлена побайтово.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 05:20:39 -04:00
ddmitry 96829718f9 Merge pull request 'docs(dds): уточнён публичный контракт события' (#162) from 150-dds-event-review-fixes into main 2026-08-26 00:54:21 +03:00
ddadmin ae5bd31dbd docs(dds): уточнён публичный контракт события
- Зачем:
  - Закрепить реалистичную границу доступа BI и убрать двусмысленность для неизвестных кодов.
- Что:
  - Пояснены доступ к ODS, пояс счётчика и порядок DDL.
  - Проверка равенства строк отделена от размера конкретной загрузки.
- Проверка:
  - `git diff --check`.
  - `make config-test` не стартовал: Snap требует работающий `snapd.apparmor`.
2026-08-25 17:53:22 -04:00
ddmitry b8a5a6171c Merge pull request 'feat(dds): добавлен публичный контракт события' (#161) from 150-dds-event-contract into main
Reviewed-on: #161
2026-08-26 00:45:37 +03:00
ddadmin 9ea361d199 feat(dds): добавлен публичный контракт события
Зачем:
- Показать слой DDS как договор данных, которому не нужна физическая копия.
- Сохранить простой учебный стенд без отдельного маршрута ради узких прав.

Что:
- Добавлено кластерное представление dds.event_v с явными 44 колонками.
- Роли bi_reader разрешено чтение ODS, DDS и DM при договоре чтения через _v.
- Согласованы мастер-спека, ADR, справочники архитектуры и живые тикеты этапа.
- Зафиксировано исследование поведения DEFINER поверх Distributed.

Проверка:
- make config-test
- make smoke
- make check-clickhouse
- Приёмочные запросы: равенство ODS/DDS, состав колонок, коды устройств и права bi.
2026-08-25 17:13:35 -04:00
ddmitry 4ea9591a16 Merge pull request 'Опись мира: счётчики классов A и C' (#160) from 157-inventory-class-counters into main 2026-08-25 20:13:38 +03:00
ddadminandClaude Fable 5 f45ad53b7f docs(dq): classes_vs_inventory названа технической проверкой
- Зачем:
  - сверка с описью подглядывает внутрь генератора: у настоящего магазина
    такого оракула нет, и выдавать её за учебный урок DQ нельзя (развилка
    из комментария к #157).
- Что:
  - в dm.md проверка счётчиков классов помечена технической, её обещание
    сужено честно: до витрин доехал рождённый мир; верно ли мир рождён,
    проверка не видит — опись считает тот же генератор.
  - в §8 мастер-спеки читатель счётчиков «самопроверка лабы сверки»
    заменён на техническую проверку classes_vs_inventory звена DQ.
  - словарь: короткий термин «Техническая проверка» в CONTEXT.md — только
    язык, решение живёт в dm.md; та же формулировка читателя в inventory.md.
- Проверка:
  - grep -rn "лабы сверки" docs/ — упоминаний не осталось.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:47:48 -04:00
ddadmin 1267b45f47 feat(generator): добавлены счётчики классов заказов
Зачем:
- дать описи контрольные числа для закрытых дней заказного окна;
- зафиксировать словарь наблюдаемых классов без изменения самого мира.

Что:
- посчитаны match, cancelled и amount_delta с приоритетом отмены;
- счётчики добавлены только к дням с закрытым окном;
- опись, тесты и документация приведены к новому формату.

Проверка:
- make lint;
- make typecheck;
- make test — 429 passed;
- uv run pytest tests/test_inventory.py -q — 3 passed после ревью.
2026-08-25 11:35:31 -04:00
ddmitry f1fee5f1d1 Merge pull request 'Слой витрин DM: форма по зерну, дележ с менти, контур DQ' (#147) from 135-dm-marts into main
Reviewed-on: #147
2026-08-25 13:38:21 +03:00
ddadminandClaude Fable 5 2f9a26eb0e docs(dm): вычитан слой витрин вторым горячим ревью
- Зачем:
  - после переписывания ритма пересборки в тексте остались швы прежних
    формулировок и один разрыв с докой конвейера.
- Что:
  - довод против дневных партиций daily_traffic заменён верным: партиция —
    единица замены, а витрина заменяется только целиком (число строк тут
    ни при чём, иначе довод бил бы и по dq_summary).
  - в etl.md к механике REPLACE PARTITION добавлена вторая идемпотентность
    конвейера — полная перезапись в ReplacingMergeTree.
  - правило ритмов говорит про день, а не партицию; поправлены переносы
    строк и обороты.
- Проверка:
  - make config-test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 06:37:09 -04:00
ddadminandClaude Fable 5 92d834761c docs(dm): ритм пересборки витрин выведен из подвижности источника
- Зачем:
  - обсуждение горячего ревью показало: «окно K для всех» — ритм заказов,
    натянутый на слой, а замерзание daily_traffic обесценивало витрину.
- Что:
  - раздел «Объём пересборки» заменён «Ритмом пересборки»: три ритма —
    хвост / окно K / целиком; daily_traffic наследует у карты полный
    пересчёт в ReplacingMergeTree, дневные партиции ей не положены.
  - в отклонённые добавлены замерзание за окном K и инкремент по
    first_matched_at; инкремент стал идеей лабы повышенной сложности #149.
- Проверка:
  - make config-test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 06:33:10 -04:00
ddadminandClaude Fable 5 99496971e2 docs(dm): вычитана дока слоя витрин
- Зачем:
  - горячее ревью развилки #135 нашло два незакрытых решения: объём
    пересборки витрин и судьба менти-витрины после постройки.
- Что:
  - добавлен раздел «Объём пересборки»: одно окно K для всех витрин,
    замерзание daily_traffic за окном названо решением с объяснением.
  - в ТЗ менти добавлен финальный пункт — встроить витрину в dm_transform.
  - поправлены мелочи: greenplum-стенд больше не зовётся предшественником,
    добавлены ссылки на спеку и карту идентичностей.
- Проверка:
  - make config-test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 04:57:17 -04:00
ddadminandClaude Fable 5 70a9aeddb7 docs(dm): спроектирован слой витрин DM
- Зачем:
  - развилка #135 карты #127 решена: форма и состав витрин DM,
    контур DQ этапа 4; резолюция переносится в доки architecture.
- Что:
  - создана docs/architecture/dm.md: форма по зерну, дележ витрин
    между стендом и заданиями менти, dq_summary с REPLACE PARTITION,
    звено DQ конвейера и даг самопроверки домашек.
  - ссылки на #135 в etl.md и dds/README.md заменены на доку слоя.
- Проверка:
  - make config-test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 04:40:30 -04:00
ddmitry 70270c2d54 Merge pull request 'Дневной конвейер трансформаций: etl_pipeline рождается этапом 4' (#146) from 134-etl-pipeline into main 2026-08-25 10:26:53 +03:00
ddadminandClaude Fable 5 d1aad3b143 docs(etl): вычитан дневной конвейер
Зачем: горячее ревью доки перед слиянием PR #146.

Что: «единственный на стенде» больше не читается как «единственный даг
стенда»; в пункте про живой день причина порядка триггера названа один раз
и по делу; конвейер дёргает работник живого дня, а не работники; в
ретро-пометке ADR 0009 расставлены кто кого дёргает.

Проверка: перечитка диффа.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 03:25:08 -04:00
ddadminandClaude Fable 5 ebe3de4ae8 docs(etl): спроектирован дневной конвейер трансформаций
Зачем: развилка #134 карты #127 — чем и когда гоняются трансформации DDS
и витрины-таблицы, пока спека числила etl_pipeline за этапом 5.

Что: дока docs/architecture/etl.md — даг etl_pipeline рождается этапом 4:
дёргает dds_transform и dm_transform последовательно (DQ — будущее звено);
живой день зовёт его с ожиданием после установки позиции, после разгона
пачкой менти запускает рукой, world_initialize — последним шагом; параметров
нет, объём выводится из world_position и состояния слоёв; первый шаг —
ожидание хвоста hits; механика REPLACE PARTITION — этап 4, этапу 5 —
партиционная переобработка. Указатели в доках заказа, сессии, README слоя
и ретро-пометки в ADR 0008/0009 переведены на новую доку.

Проверка: вычитка; резолюция и доводы — в тикете #134.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 03:22:35 -04:00
ddadmin f0c4b4c627 docs(dds): зафиксирована физическая модель карты идентичностей
- Зачем:
  - согласованная модель нужна для пакетной реализации DDS и понятного публичного интерфейса слоя.
- Что:
  - описаны физическая карта идентичностей, полный пересчёт и отказ от first_matched_at.
  - унифицирован публичный контракт _v для событий, заказов, сессий и карты.
  - синхронизированы мастер-спека, имена объектов и граница доступа.
- Проверка:
  - git diff --cached --check.
2026-08-24 15:52:17 -04:00
ddmitry 256d51840b Merge pull request 'Контракт dds.event_v: язык модели, расшифровки, скрытые колонки' (#144) from 132-dds-event-contract into main
Reviewed-on: #144
2026-08-24 21:11:59 +03:00
ddadminandClaude Fable 5 7fc12fb8c9 docs(dds): вычитан контракт события
- Зачем:
  - горячее ревью резолюции #132 нашло шероховатости, мешающие читателю.
- Что:
  - срезан дубль про event_date из «Языка модели» — факт живёт в «Форме»,
    расхождений имён осталось два.
  - выровнены префиксы dm. в «Читателях», распутана фраза про два слоя
    DQ-сверки, ссылка на спеку — в домашнем стиле.
- Проверка:
  - вычитка.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:09:15 -04:00
ddadminandClaude Fable 5 92ed3011e6 docs(dds): спроектирован контракт dds.event_v — язык модели поверх ODS
- Зачем:
  - развилка #132 карты #127: витринам DM и сборке сессий нужен контракт
    события на языке бизнес-модели, первый пример правила «слой — это
    контракт, а не обязательно копия данных».
- Что:
  - создан docs/architecture/dds/event.md: представление над ods.event_v,
    имена модели (event_id, event_time в поясе счётчика, event_date
    переименованием), расшифровка device_category, скрыты VisitID, Sign,
    ecommerce и служебные колонки.
  - в README карта модели ведёт на event.md, развилка убрана из открытых
    решений; session.md ссылается на резолюцию судьбы VisitID.
- Проверка:
  - вычитка; относительные ссылки в docs/architecture/dds/ ведут на
    существующие файлы.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:04:51 -04:00
ddmitry 83f4e0952b Merge pull request 'Модель сессии в DDS: нарезка стендом, эталон VisitID, дневная партиция' (#142) from 131-dds-session-model into main
Reviewed-on: #142
2026-08-24 20:42:03 +03:00
ddadminandClaude Fable 5 4d43b7badd docs(dds): дописаны следствия ключей сессии — локальность и судьба VisitID
Зачем: две находки горячего ревью #131 уровня решения, принятые владельцем.

Что: в «Движок и наполнение» — следствие совпадения ключа шардирования с
событиями: сессионизация локальна по природе, капкан смены ключа у сессий
не стреляет; в «Сверку с эталоном» — зацепка для развилки #132: VisitID
нужен сверке, но не сборке, его дом решает контракт dds.event_v.

Проверка: вычитка; утверждение о локальности сверено с разделом
«Раскладка по шардам» доки хранилища.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:40:45 -04:00
ddadminandClaude Fable 5 9237870ab5 docs(dds): вычитана модель сессии
Зачем: горячее ревью резолюции #131 перед мержем PR #142.

Что: поправлена фраза про торговые события (они держат сессию, а не
«рвут»), метка времени названа явно (UTCEventTime), убрано задвоение
про «цели» между составом строки и списком расхождений, у счётной
сверки честно назван предел — взаимная компенсация ошибок внутри
куки за день счёту не видна.

Проверка: повторная вычитка; утверждения сверены с CONTEXT.md, спекой
(разделы 1, 6, 7) и докой хранилища.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:37:14 -04:00
ddadminandClaude Fable 5 6f2692737b docs(dds): спроектирована модель сессии — нарезка стендом, эталон VisitID
Зачем: развилка #131 карты #127 — зерно и правила сборки dds.session;
резолюция переносится в доки слоя тем же PR.

Что: дока docs/architecture/dds/session.md — зерно «один визит», нарезка
по правилу визита самим стендом (стенд в роли сессионизатора), session_id
как детерминированный хеш, состав строки по образцу визита Метрики без
денег и целей, MergeTree с заменой дневной партиции, сборка одним дневным
батчем после слепка, счётная DQ-сверка с VisitID (кука × день); в README
слоя сессия переведена из открытых решений в карту модели.

Проверка: вычитка доки; ссылки на разделы спеки и соседние доки сверены
с текстом.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:32:49 -04:00
ddmitry 111315990b Merge pull request 'Модель заказа в DDS: зерно, массивы позиций, замена партиций' (#141) from 130-dds-order-model into main
Reviewed-on: #141
2026-08-24 19:09:30 +03:00
ddadminandClaude Fable 5 40de2e5c5f docs(dds): вычитаны модель заказа и карта слоя
- Зачем:
  - горячее ревью после написания: неточности формулировок и один пропуск
    в списке читаемых колонок.
- Что:
  - уточнено, что дом набора выбран развилкой #130, а не «первой развилкой
    карты» (первыми шли research-тикеты);
  - сверке добавлен order_date в перечень читаемых колонок (спека, §7:
    колонка order_day);
  - мастер-спека слинкована при первом упоминании; пояснена роль резолюции
    #128 в «Связанных решениях».
- Проверка:
  - только документы; ссылки сверены с деревом docs/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:06:54 -04:00
ddadminandClaude Fable 5 7972847601 docs(dds): спроектирована модель заказа в DDS и заведён набор доков слоя
- Зачем:
  - развилка #130 карты #127: заказу нужен объект в DDS — зерно, состав,
    движок и судьба служебных колонок; резолюции этапа 4 нужен дом в git.
- Что:
  - создан набор docs/architecture/dds/ по ADR 0011: README с картой
    модели и правилами слоя, order.md с моделью заказа — зерно «заказ в
    текущем состоянии», позиции массивами, MergeTree с заменой партиций,
    свои _load_id/_load_ts вместо ярлыков ODS (закрывает наследство #85);
  - указатели на модель добавлены в orders/README.md, orders/ingestion.md
    и storage.md.
- Проверка:
  - только документы; относительные ссылки сверены с деревом docs/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:03:26 -04:00
ddmitry 1adacc6d7c Merge pull request 'docs(research): соединение Distributed-таблиц на кластере' (#140) from research/distributed-join into main 2026-08-22 11:42:09 +03:00
ddmitry d359882e1d Merge pull request 'docs(research): проталкивание условий сквозь представления над FINAL' (#139) from research/view-predicate-pushdown into main 2026-08-22 11:42:05 +03:00
ddmitry 544e500dc8 Merge pull request 'Разложить заказы в ODS по своему ключу шардирования' (#138) from 137-orders-shard-colocation into main
Reviewed-on: #138
2026-08-22 11:38:55 +03:00
ddadminandClaude Opus 5 45360b6bcf docs(ods): вычитаны комментарии и документация починки раскладки
- Зачем:
  - правки #137 дважды рассказывали одно и то же: механика капкана жила и в
    storage.md, и у вставок, а цифры замеров — и в прозе, и в леджере;
    у читателя это спрашивает больше внимания, чем даёт.
- Что:
  - комментарии у вставок order_parse.sql сжаты до довода с указателем на
    storage.md; из капкана в storage.md цифры ушли в «Что проверено»;
    в ingestion.md замеры перенесены из прозы в свой раздел «Что проверено».
- Проверка:
  - рендер order_parse.sql по-прежнему делится ровно на два запроса
    (из комментария убрана точка с запятой, по которой оператор делит файл).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 04:35:41 -04:00
ddadminandClaude Opus 5 94a1ffeee2 docs(ods): описана семантика snapshot_date после схлопывания версий
- Зачем:
  - проверка #137 показала: у заказа, не менявшегося несколько дней, версию
    выбирает только updated_at, и какая из одинаковых строк переживёт
    схлопывание — не обещано; snapshot_date выжившей строки означал не то,
    что обещала документация (у 86 заказов стартового мира выжил серединный
    день наблюдения).
- Что:
  - в docs/architecture/orders/ingestion.md записано: snapshot_date, _load_id
    и _load_ts выжившей строки — координаты одного из наблюдений версии, не
    обязательно последнего; допущение «уточнение поднимает updated_at»
    названо допущением с границей применимости.
  - та же оговорка — в комментарии DDL ods.order_rep.
- Проверка:
  - замеры на живом стенде: во всех 3034 парах строк с равным updated_at
    деловые поля совпали побайтово; монотонность updated_at по слепкам —
    без нарушений; ods.order_v сошёлся с эталоном argMax из сырья построчно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 04:28:51 -04:00
ddadminandClaude Opus 5 8c76a7a0d8 fix(ods): заказы разложены по своему ключу шардирования
- Зачем:
  - заводское parallel_distributed_insert_select = 2 исполняло вставку из
    Distributed в Distributed локально на каждом шарде, минуя ключ цели:
    1189 из 1662 заказов задвоились на обоих шардах, а ReplacingMergeTree
    через границу шарда дубли не схлопывает.
- Что:
  - в обе вставки sql/ods/order_parse.sql добавлено явное
    parallel_distributed_insert_select = 0 с доводом комментарием.
  - в docs/architecture/storage.md описан капкан «INSERT из Distributed в
    Distributed при несовпадающих ключах» и записана проверка (#137).
- Проверка:
  - межшардовые дубли по ods.order_rep дают 0 (было 1189), каждый шард
    держит только свои ключи; uniqExact(order_id) не изменился.
  - после прожитого дага world_next_day новый слепок принят, дублей
    по-прежнему 0.
  - make smoke, make check-clickhouse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 04:02:52 -04:00
ddadminandClaude Fable 5 f218795472 docs(research): изучено соединение Distributed-таблиц на кластере
Зачем: тикет #129 (карта #127) требовал проверить, где выполняется JOIN
двух Distributed-таблиц, что даёт GLOBAL и как влияет
distributed_product_mode, — от этого зависят сборка dds.identity_map и
витрина сверки purchase_vs_orders_v этапа 4.

Что: заметка docs/research/2026-08-22-distributed-join.md. Ключевое:
GLOBAL рассылает правую часть с инициатора на шарды; режим local молча
даёт неверный ответ без настоящей ко-локации; подзапрос слева переносит
соединение на инициатор; FULL OUTER с голой левой таблицей задваивает
непарные строки правой части. Попутная находка: ко-локация заказов по
cityHash64(order_id) сломана заводским parallel_distributed_insert_select=2
— починка отдельным тикетом.

Проверка: читающие запросы на живом стенде 26.3.17.56 (log_comment
r129-* в system.query_log обеих нод) и документация ClickHouse через
MCP Context7; стенд не менялся, main не тронут.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 03:39:26 -04:00
ddadminandClaude Fable 5 86ee901dd0 docs(research): проверено проталкивание условий сквозь представления над FINAL
Зачем: контракт будущего dds.event_v зависит от того, доезжают ли условия
запроса сквозь цепочку обычных представлений до индексов таблицы с FINAL,
или представление разворачивается целиком и фильтруется потом (#128).

Что: research-заметка с опытами на живом стенде (EXPLAIN indexes/actions,
сверка read_rows по system.query_log) и сверкой с документацией ClickHouse
через MCP Context7. Ответ: условия доезжают сквозь два уровня, включая
переименования и анализируемые выражения; FINAL отключает только
автоматический PREWHERE. Цепочка представлений ODS -> DDS -> DM годится.

Проверка: только читающие запросы, состояние стенда не менялось; вложенное
представление эмулировано подзапросом.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 03:33:55 -04:00
ddmitry ea39982bdd Merge pull request 'Подключить официальный провайдер ClickHouse к Airflow' (#126) from 124-airflow-clickhouse-provider into main
Reviewed-on: #126
2026-08-22 10:06:05 +03:00
ddadmin 20b0666d28 fix(airflow): добавлен повтор запуска DAG после индексации
- Зачем:
  - при холодном старте GET уже видел DAG, пока POST запуска ещё считал его устаревшим.
- Что:
  - точный стартовый ответ 404 повторяется до двадцати раз с паузой три секунды.
  - прочие ответы и сетевые ошибки завершают команду без риска двойного запуска.
  - граница повторов объяснена в коде и документации проверок.
- Проверка:
  - выполнены `bash -n scripts/run-dag.sh`, `make config-test` и изолированные сценарии ответов API.
  - выполнены холодный `make up`, `make lint` и `make smoke` (21/21).
2026-08-22 02:20:01 -04:00
ddadmin 8d82679bc1 feat(airflow): подключён официальный провайдер ClickHouse
- Зачем:
  - нужен единый штатный путь исполнения SQL и программных запросов ClickHouse из Airflow.
- Что:
  - добавлен официальный провайдер и подключения типа `clickhouse`.
  - SQL-файлы переданы `SQLExecuteQueryOperator`, а программные запросы — `ClickHouseHook`.
  - разбор заказов собран в одном файле, DDL разделён по задачам, документация и смоук обновлены.
- Проверка:
  - выполнены `make lint` и `make config-test`.
  - собран образ, выполнены `pip check`, `make rebuild-storage`, `make smoke` (21/21), `make check-clickhouse` (10/10) и `make check-services` (7/7).
2026-08-22 01:40:56 -04:00
ddmitry 587e392220 Merge pull request 'Передать жизненный цикл мира Airflow' (#125) from 119-world-lifecycle into main
Reviewed-on: #125
2026-08-21 23:55:50 +03:00
ddadmin bca1ffa379 fix(world): устранены расхождения после переноса на main
- Зачем:
  - свежий main добавил мониторинг и проявил устаревшие доводы, мёртвые настройки и слишком короткий тайм-аут DDL.
- Что:
  - документация приведена к текущему жизненному циклу мира и составу стенда;
  - удалены лишняя проверка и неиспользуемый YAML-якорь;
  - ожидание ClickHouse возвращено к 300 секундам, а отказы и параметры DockerOperator пояснены.
- Проверка:
  - `make config-test`;
  - `make lint`;
  - `make smoke` — 21/21.
2026-08-21 23:38:45 +03:00
ddadmin 7e6554507a docs(world): описан жизненный цикл прикладного мира
- Зачем:
  - границы владения Compose и Airflow должны быть видны менти из одного источника истины.
- Что:
  - добавлены спека и ADR жизненного цикла мира;
  - обновлены README, архитектурные справочники и прежние решения;
  - правило полной очистки записано как явное и проверяемое требование к агенту.
- Проверка:
  - документация вычитана через `ai-text-lint` и `writing-for-agents`;
  - выполнена литературная вычитка русского текста;
  - `git diff --check`.
2026-08-21 23:02:15 +03:00
ddadmin fc5244cc67 feat(world): жизненный цикл передан Airflow
- Зачем:
  - Compose должен поднимать инфраструктуру, а Airflow — владеть прикладным миром.
- Что:
  - добавлены даги создания и пересоздания мира с явными состояниями позиции;
  - цели make переведены на REST API Airflow, старые одноразовые службы удалены;
  - добавлена ограниченная учётная запись ClickHouse для прикладного DDL.
- Проверка:
  - `make lint`, `make config-test`, `bash -n scripts/run-dag.sh infra/airflow/init.sh`;
  - `make lint` и `make typecheck` в `generator/`;
  - холодный `make up`, повторный `make up`, `make rebuild-storage` и сценарий оборванной очистки;
  - `make smoke`, `make check-clickhouse`, `make check-services`.
2026-08-21 23:01:11 +03:00
ddmitry 0340b060f2 Merge pull request 'Мониторинг: дашборды «Кластер» и «Запросы»' (#123) from feat/116-cluster-queries-dashboards into main
Reviewed-on: #123
2026-08-21 22:34:22 +03:00
ddadmin 48bec014cf fix(monitoring): доводы приведены к замерам, пары нод больше не расходятся
- Зачем:
  - второй круг ревью нашёл, что текст спорит с собственной приёмкой: окно
    держалось доводом «слияния за минуту-две», а замер даёт двадцать секунд;
    довод панели ошибок опирался на отказ по памяти, которого ADR 0004 на этом
    стенде не обещает.
- Что:
  - «Части» сортируются по таблице: сортировка по числу частей разводила две
    ноды одной таблицы, как только между ними вставала чужая.
  - у «Слияний» столбик шириной в минуту, иначе редкие события выходили
    волосяной линией; из заголовка убран «слой» — фильтр отсекает журналы
    сервера, а не выбирает слои.
  - в cluster.md исправлены довод за окно, ложное «времени в system.parts нет»,
    счёт панелей без нод (их две, не одна) и разрешение, в котором снят замер
    процессора.
  - панель ошибок и её запись в ADR 0002 переписаны под проверенное: отказ на
    шарде воспроизведён принудительным пределом памяти.
  - из queries.md убран абзац про SELECT 1 — он объяснял пол удалённой панели.
- Проверка:
  - make config-test; make smoke 21/21; make check-clickhouse 10/10.
  - все десять панелей отвечают через /api/ds/query в окнах на полчаса, час и
    шесть часов; шаг столбика слияний проверен отдельным запросом.
2026-08-21 15:28:03 -04:00
ddadmin 42075ff9fb fix(monitoring): панели уточнены по ревью, приёмка снята под нагрузкой
- Зачем:
  - слияния в верхних строках показывали только журналы самого сервера, а
    предел в 15 строк рвал пару нод пополам: панели отвечали не на свой вопрос.
- Что:
  - «Слияния» стали столбиками по слоям: журналы сервера отделены, ряды ods и
    stg видны своими.
  - «Части» показывают все таблицы слоёв целиком, без предела и без обрезанных
    пар.
  - у «Запросов» убран «Поток запросов» — его пол составляли пробники
    состояния; у «Кто спрашивает» убрана лишняя колонка.
  - ряд «коробка» назван «отведено» — как в заголовке панели.
  - доводы дописаны в cluster.md и queries.md, отступления от ADR 0002
    записаны в сам ADR.
- Проверка:
  - make config-test; make smoke 21/21; make check-clickhouse 10/10.
  - все десять панелей отвечают через /api/ds/query в окнах на полчаса, час и
    шесть часов.
2026-08-21 15:11:21 -04:00
ddadmin 7ffb4606f1 feat(monitoring): собраны дашборды «Кластер» и «Запросы»
- Зачем:
  - ADR 0002 обещает три дашборда; собран был один, а симптом инфраструктуры
    как следствие решения человека показать было негде.
- Что:
  - cluster.json — семь панелей: процессор, память с пределом коробки, диски,
    части по таблицам, слияния, keeper, очередь распределённых DDL.
  - queries.json — четыре панели поверх system.query_log: тяжёлые запросы,
    поток запросов, аппетит по пользователям, ошибки.
  - системные таблицы читаются через clusterAllReplicas; очередь DDL — без
    обёртки, она живёт в keeper и у обеих нод одна.
  - устройство обоих дашбордов записано в docs/architecture/monitoring/,
    правило про пороги поднято из data.md в README набора.
- Проверка:
  - make config-test; make smoke 21/21; make check-clickhouse 10/10.
  - все одиннадцать панелей отвечают через /api/ds/query.
2026-08-21 14:52:58 -04:00
ddmitry 272660ff66 Merge pull request 'Мониторинг: справочник зоны — в набор документов' (#122) from feat/121-monitoring-docs into main
Reviewed-on: #122
2026-08-21 21:19:03 +03:00
ddadmin 06c77c5ed2 docs(monitoring): «скребёт» заменено, поправлены падеж и тяжёлые фразы
- Зачем:
  - «что скребёт Prometheus» — калька со scrape, по-русски звучит коряво;
    рядом нашлись ошибка падежа и пара фраз, которые читаются с запинкой.
- Что:
  - заголовок набора и раздел названы по-русски: «за чем следит» и «кого
    опрашивает Prometheus».
  - «Единицы общие — сообщений за интервал» исправлено на именительный падеж.
  - разбиты две перегруженные фразы: про место диагностики версий и про то,
    что читать на панели сходимости.
- Проверка:
  - make config-test.
2026-08-21 14:15:57 -04:00
ddadmin 28177cff92 docs(monitoring): вводный абзац не повторяет каталог дашбордов
- Зачем:
  - проход на вычитание: каталог дашбордов появился в этом же PR, и абзац
    «что здесь есть» стал называть «Данные» второй раз.
- Что:
  - вводный абзац отсылает к каталогу, а имя дашборда и состав панелей
    остались только в самом каталоге.
- Проверка:
  - make config-test.
2026-08-21 14:07:35 -04:00
ddadmin ee0306d19e docs(monitoring): убраны повтор заголовков, запрет и двойной довод
- Зачем:
  - таблица панелей дублировала заголовки из data.json и уже правилась
    дважды; заголовок раздела называл запрещённое действие вместо нужного;
    довод про отвергнутые способы счёта сходимости стоял в двух местах.
- Что:
  - в таблице панелей остались только источники, вопрос панели читается с её
    заголовка на экране.
  - раздел про чтецов Kafka назван по тому, что панели делают, опасность
    kafka_commit_on_select осталась внутри как причина.
  - из записи о приёмке убран пересказ довода, замер оставлен.
- Проверка:
  - make config-test; make lint.
2026-08-21 14:01:32 -04:00
ddadmin 61eb2680ff docs(monitoring): приёмка дашборда переехала к дашборду
- Зачем:
  - в общем разделе «Что проверено» лежали два разных прогона, пола и
    дашборда; следующие дашборды копили бы там осадок.
- Что:
  - запись о приёмке дашборда «Данные» перенесена в data.md, в README.md
    осталась приёмка самой зоны.
- Проверка:
  - make config-test.
2026-08-21 14:00:33 -04:00
ddadmin 91e281fd32 docs(monitoring): справочник зоны разрезан на набор документов
- Зачем:
  - половина справочника была про один дашборд «Данные», а этап приносит ещё
    два дашборда и правила оповещения: описание самой зоны утонуло бы.
- Что:
  - заведён набор docs/architecture/monitoring/ по ADR 0011: README.md — зона
    и каталог дашбордов, data.md — устройство дашборда «Данные».
  - обе входящие ссылки перенацелены: README.md репозитория и ADR 0002.
- Проверка:
  - make config-test; ссылки из README.md и ADR 0002 открываются.
2026-08-21 14:00:05 -04:00
190 changed files with 17907 additions and 2908 deletions

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ CLICKHOUSE_02_TCP_PORT=29001
CLICKHOUSE_CLUSTER_SECRET=cluster-26-secret CLICKHOUSE_CLUSTER_SECRET=cluster-26-secret
CLICKHOUSE_DEFAULT_PASSWORD=default-26-local CLICKHOUSE_DEFAULT_PASSWORD=default-26-local
CLICKHOUSE_ETL_PASSWORD=etl-26-writer CLICKHOUSE_ETL_PASSWORD=etl-26-writer
CLICKHOUSE_LIFECYCLE_PASSWORD=lifecycle-26-owner
CLICKHOUSE_BI_PASSWORD=bi-26-reader CLICKHOUSE_BI_PASSWORD=bi-26-reader
CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader
CLICKHOUSE_GRAFANA_PASSWORD=grafana-26-reader CLICKHOUSE_GRAFANA_PASSWORD=grafana-26-reader
+38 -4
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, сообщения
@@ -82,6 +96,11 @@ Airflow) и названия из кода. Если для понятия ес
`generator/` — код генератора. Правишь даги или Superset — зови корневую, `generator/` — код генератора. Правишь даги или Superset — зови корневую,
правишь генератор — зови из `generator/`. правишь генератор — зови из `generator/`.
- Изменения держать минимальными и в границах задания. - Изменения держать минимальными и в границах задания.
- `make clean` полностью удаляет постоянное состояние стенда. Перед этой
командой назови состояние вне прикладного мира, которое требуется удалить,
и объясни, почему `make rebuild-storage` его не удаляет. При изменении DDL
или проверке на чистых данных по умолчанию используй адресную очистку либо
`make rebuild-storage`.
- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук - Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук
обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md). обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md).
- Секреты не коммитить: настройки — через `.env`, образец — `.env.example`. - Секреты не коммитить: настройки — через `.env`, образец — `.env.example`.
@@ -152,6 +171,21 @@ Airflow) и названия из кода. Если для понятия ес
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
истории, а текущее описание живого формата — и собирается из кода, а не истории, а текущее описание живого формата — и собирается из кода, а не
пишется руками. пишется руками.
- Имена файлов основной программы в `docs/labs/` — `S-N-slug.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`). Решения нумеруются подряд, дата
в имени не нужна. в имени не нужна.
+7 -1
View File
@@ -1 +1,7 @@
@AGENTS.md @AGENTS.md
# Читабельность языка
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
Please remove all mannered prose.
+237 -173
View File
@@ -1,211 +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).
источника. Правится только контракт, документ пересобирается. По нему строится прием данных в хранилище. Документ собирается из
_Избегать_: описание схемы, документация контракта описаний колонок генератора: правят их, затем пересобирают документ.
_Избегать_: описание схемы, документация контракта.
**Канонический сериализатор**: **Техническая проверка**:
Единственная граница, где запись генератора превращается в байты. Фиксированный Проверка по внутреннему эталону стенда, например сравнение числа событий
порядок ключей и строк — основа побайтовой воспроизводимости. с паспортом мира. Проверка качества данных (DQ) опирается на данные
магазина и связи между ними; ей не нужен эталон генератора, которого
у настоящего магазина нет.
_Избегать_: DQ-проверка в значении проверки по паспорту мира.
**Проигрыватель**: ### Заказы
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
(пачкой, без темпа) и живой день.
**Слепок**: **Слепок**:
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток: Дневная выгрузка состояния заказов из серверной части магазина.
состояние заказов на этот момент, а не поток их изменений. Опоздавший заказ Она содержит заказы, оформленные за последние семь модельных дней,
может отсутствовать в ранних слепках; после первого появления ездит до конца с учетом изменений к концу дня. Опоздавший заказ может отсутствовать
своего окна. в первых слепках.
**Дата слепка**: **Дата слепка**:
Дата завершившегося модельного дня, состояние которого снято на исходящей Дата модельного дня, на конец которого снято состояние заказов;
границе суток. Одна для всех записей выгрузки; не дата заказа и не время поле `snapshot_date` одинаково во всех строках выгрузки. Даты оформления
отправки сообщения. заказов могут различаться; выгрузка отправляется на следующий модельный день.
**Время аудита источника**: **Время аудита источника**:
Время создания или последнего изменения строки заказа по часам базы источника. Время создания и последнего изменения записи заказа по часам базы
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в магазина: поля `created_at` и `updated_at`. Время покупки в событии
событии `purchase` отдельно. `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 или файл. В пакетном режиме он отправляет их без пауз,
в режиме живого дня — с ускорением по времени событий.
+14 -8
View File
@@ -4,23 +4,27 @@ GENERATOR_LIMIT ?=
GENERATOR_SPEED ?= GENERATOR_SPEED ?=
# Цели корня — про стенд; проверки генератора — в `generator/Makefile`. # Цели корня — про стенд; проверки генератора — в `generator/Makefile`.
.PHONY: up down clean ps logs smoke check-clickhouse check-services config-test lint generate-batch generate-live .PHONY: up down clean rebuild-storage ps logs smoke check-clickhouse check-services config-test lint generate-batch generate-live
# --- Жизнь стенда --- # --- Жизнь стенда ---
# Второй шаг: `--wait` дожидается служб, а приём событий асинхронный — довод # Compose дожидается инфраструктуры, Airflow — готового прикладного мира.
# целиком в шапке скрипта.
up: up:
$(COMPOSE) up --detach --build --wait --wait-timeout 600 $(COMPOSE) up --detach --build --wait --wait-timeout 600 --remove-orphans
COMPOSE_BIN="$(COMPOSE)" ./scripts/wait-for-world.sh COMPOSE_BIN="$(COMPOSE)" ./scripts/run-dag.sh world_initialize
COMPOSE_BIN="$(COMPOSE)" ./scripts/ingest-starting-orders.sh
down: down:
$(COMPOSE) down --remove-orphans $(COMPOSE) down --remove-orphans
# Полное удаление стенда, а не способ получить чистые данные перед проверкой.
# Граница операции и требование к обоснованию — в AGENTS.md.
clean: clean:
$(COMPOSE) down --volumes --remove-orphans $(COMPOSE) down --volumes --remove-orphans
# Возврат прикладного мира к началу без удаления инфраструктурных томов.
rebuild-storage:
COMPOSE_BIN="$(COMPOSE)" ./scripts/run-dag.sh world_recreate
ps: ps:
$(COMPOSE) ps $(COMPOSE) ps
@@ -47,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 -361
View File
@@ -1,405 +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`, а `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 clean`.
Повторный `make up` безопасен: одноразовая подготовка приложений идемпотентна.
## Стартовый мир
Стенд поднимается не пустым: разовая служба `world-init` играет в топик `hits`
первые восемь дней модельного времени — понедельник по понедельник, 401 185
событий. Следом второй запуск того же генератора отправляет в топик `orders`
семь слепков — за дни 0…6. Дальше оба источника разбирает хранилище, и к концу
`make up` события лежат в `ods.event`, а заказы — в `ods.order_v`. Так у всякой
лабы есть данные, и всегда одни и те же.
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
виден разный темп потокового трекера и ночной выгрузки магазина.
Ждать приходится дольше, чем работает заливка. `make up` сначала опрашивает
ClickHouse, пока события не доедут, затем разово запускает даг `orders_ingest`
и ждёт его конца. Повторный `make up` заливает мир заново; это не ошибка, а
свойство: номера событий и версии заказов те же, и повторы схлопнут
`ReplacingMergeTree`.
Сам мир в 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
```
Тот же образ несёт заливка стартового мира, поэтому свежим его держит и
обычный `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_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
Работник не только играет день: следом он отправляет слепок заказов за
сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший
слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины
стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на
месте ([ADR 0008](docs/adr/0008-order-ingestion.md)).
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон
позицию не двигает, и следующий запуск играет тот же день заново. Нет
переменной — мир в стартовом состоянии.
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
целиком — [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` применяет DDL и подключает Airflow; роль `etl_writer` читает и пишет ## Добавить данные за следующие дни
слои хранилища;
- `bi` подключает Superset; роль `bi_reader` читает справочники `dic` и будущие
слои DDS и DM;
- `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.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).
Для пробников проверены публичный `Connection.get` из `airflow.sdk` и клиент
`clickhouse-connect`. Официальный провайдер Kafka сам использует
`confluent-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) — правила работы в репозитории для ИИ-агентов.
+27 -148
View File
@@ -17,6 +17,7 @@ x-clickhouse-common: &clickhouse-common
CLICKHOUSE_CLUSTER_SECRET: ${CLICKHOUSE_CLUSTER_SECRET:?Скопируйте .env.example в .env} CLICKHOUSE_CLUSTER_SECRET: ${CLICKHOUSE_CLUSTER_SECRET:?Скопируйте .env.example в .env}
CLICKHOUSE_DEFAULT_PASSWORD: ${CLICKHOUSE_DEFAULT_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_DEFAULT_PASSWORD: ${CLICKHOUSE_DEFAULT_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_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env}
@@ -33,9 +34,8 @@ 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
@@ -46,16 +46,12 @@ x-kafka-target: &kafka-target
<<: *kafka-common <<: *kafka-common
KAFKA_TOPIC: hits KAFKA_TOPIC: hits
x-orders-kafka-target: &orders-kafka-target
<<: *kafka-common
KAFKA_TOPIC: *orders-topic
x-world-starting-days: &world-starting-days "8" x-world-starting-days: &world-starting-days "8"
# Общие образы Airflow и Superset собирают службы инициализации. Причина
# отдельного build: ADR 0001.
x-airflow-common: &airflow-common x-airflow-common: &airflow-common
image: clickstream-airflow:local image: clickstream-airflow:local
build:
context: ./infra/airflow
environment: environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__PARALLELISM: "4" AIRFLOW__CORE__PARALLELISM: "4"
@@ -73,9 +69,9 @@ x-airflow-common: &airflow-common
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env} AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}
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}
# факты стенда, что compose даёт разовой службе генератора, нужны и дагам. # Даги времени поднимают генератор отдельным контейнером. Имя сети собирается
# Имя сети собирается из имени проекта: у второй копии стенда оно другое. # из имени проекта: у второй копии стенда оно другое.
<<: *kafka-target <<: *kafka-target
GENERATOR_IMAGE: *generator-image GENERATOR_IMAGE: *generator-image
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
@@ -83,9 +79,14 @@ x-airflow-common: &airflow-common
KAFKA_ORDERS_TOPIC: *orders-topic KAFKA_ORDERS_TOPIC: *orders-topic
volumes: volumes:
- ./dags:/opt/airflow/dags:ro - ./dags:/opt/airflow/dags:ro
# Запросы дагов: даг называет файл, а текст читает Airflow при исполнении # Запросы дагов: даг называет файл, а обработчик DAG при разборе загружает
# задачи. Каталог тот же, что применяет DDL, — вся SQL стенда живёт в sql/. # его текст. Каталог тот же, что применяет DDL, — вся SQL стенда живёт в sql/.
- ./sql:/opt/airflow/sql:ro - ./sql:/opt/airflow/sql:ro
# Инициализация мира собирает канонический образ из того же контекста, что
# ручной generator. Docker SDK архивирует эти файлы внутри задачи.
- ./generator:/opt/airflow/world/generator:ro
- ./data:/opt/airflow/world/data:ro
- ./.dockerignore:/opt/airflow/world/.dockerignore:ro
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro - ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
- airflow_logs:/opt/airflow/logs - airflow_logs:/opt/airflow/logs
- airflow_auth:/opt/airflow/auth - airflow_auth:/opt/airflow/auth
@@ -96,15 +97,6 @@ x-generator-common: &generator-common
context: . context: .
dockerfile: generator/Dockerfile dockerfile: generator/Dockerfile
restart: "no" restart: "no"
depends_on:
kafka-init:
condition: service_completed_successfully
# Читать топик движок Kafka начинает не при своём создании, а когда над ним
# появляется матвью приёма, и она создаётся последней по номеру файла
# (sql/ddl/40-stg-views.sql). Отправь генератор события раньше — они лягут в
# топик и молча минуют хранилище.
clickhouse-init:
condition: service_completed_successfully
environment: environment:
# Адрес брокера и имя топика — факты стенда, и называет их стенд. # Адрес брокера и имя топика — факты стенда, и называет их стенд.
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами # Остальное (день, зерно, число дней, предел пачки) приходит аргументами
@@ -113,8 +105,6 @@ x-generator-common: &generator-common
x-superset-common: &superset-common x-superset-common: &superset-common
image: clickstream-superset:local image: clickstream-superset:local
build:
context: ./infra/superset
environment: environment:
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env} SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
@@ -228,119 +218,6 @@ services:
retries: 15 retries: 15
start_period: 10s start_period: 10s
kafka-init:
image: apache/kafka:4.3.1
restart: "no"
depends_on:
kafka:
condition: service_healthy
entrypoint: ["/bin/bash", "-ec"]
command:
- |
topic_partition_count() {
/opt/kafka/bin/kafka-topics.sh \
--bootstrap-server kafka:9092 \
--describe \
--topic hits | \
sed -n '1s/.*PartitionCount: \([0-9][0-9]*\).*/\1/p'
}
/opt/kafka/bin/kafka-topics.sh \
--bootstrap-server kafka:9092 \
--create \
--if-not-exists \
--topic hits \
--partitions 2 \
--replication-factor 1
partition_count="$$(topic_partition_count)"
# Брокер разрешает автосоздание, хотя потребитель ClickHouse его не запрашивает.
# На сохранённом томе другой клиент мог уже создать hits с одним разделом.
if [[ "$$partition_count" == "1" ]]; then
/opt/kafka/bin/kafka-topics.sh \
--bootstrap-server kafka:9092 \
--alter \
--topic hits \
--partitions 2
partition_count="$$(topic_partition_count)"
fi
if [[ "$$partition_count" != "2" ]]; then
printf 'ОШИБКА: у топика hits разделов: %s, ожидалось: 2.\n' \
"$${partition_count:-неизвестно}" >&2
exit 1
fi
# Топик заказов: одна партиция, читает его один чтец на ноде 1 —
# довод в ADR 0008.
/opt/kafka/bin/kafka-topics.sh \
--bootstrap-server kafka:9092 \
--create \
--if-not-exists \
--topic orders \
--partitions 1 \
--replication-factor 1
# DDL применяется с ноды 1 по порядку имён файлов после готовности всего кластера.
clickhouse-init:
image: clickhouse/clickhouse-server:26.3.17.56
restart: "no"
depends_on:
kafka-init:
condition: service_completed_successfully
clickhouse-01:
condition: service_healthy
clickhouse-02:
condition: service_healthy
environment:
LC_ALL: C
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
entrypoint: ["/bin/bash", "-ec"]
command:
- |
set -- /ddl/*.sql
if [[ ! -e "$$1" ]]; then
printf 'ОШИБКА: в /ddl нет файлов SQL.\n' >&2
exit 1
fi
for ddl_file do
printf 'Применяем %s.\n' "$${ddl_file##*/}"
clickhouse-client \
--host clickhouse-01 \
--user etl \
--password "$$CLICKHOUSE_ETL_PASSWORD" \
--multiquery \
--queries-file "$$ddl_file"
done
volumes:
- ./sql/ddl:/ddl:ro
# Стартовый мир: восемь модельных дней уезжают в топик при каждом подъёме.
# Что это за дни и каким мир обязан выйти — data/world-inventory.json.
#
# Число дней стоит здесь числом: YAML не читает Python, и одно из двух мест
# (второе — `STARTING_DAYS` в inventory.py) лишнее по построению. Правя одно,
# правьте второе — на страже тут никто не стоит: залей эта служба лишний
# день, он лёг бы за рамкой дат описи и остался бы незамеченным. Внутри YAML
# число одно на всех: то же говорит дагам пульта, где кончается стартовый мир.
#
# Повторный `make up` заливает мир заново, и это не оплошность: `WatchID` у
# событий те же, ReplacingMergeTree схлопнет повтор в ODS. Сырьё в STG при
# этом честно удвоится — свойство слоя, описанное в storage.md.
world-init:
<<: *generator-common
command: ["batch", "--day", "0", "--days", *world-starting-days]
# Второй источник стартового мира идёт после событий тем же генератором.
# Сдвиг команды snapshot оставляет день 7 первому ходу мира: за восемь
# дней уезжают семь слепков, дней 0…6.
world-snapshots:
<<: *generator-common
depends_on:
world-init:
condition: service_completed_successfully
environment: *orders-kafka-target
command: ["snapshot", "--day", "0", "--days", *world-starting-days]
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`. # Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
# Под профилем — чтобы обычный подъём стенда её не трогал. # Под профилем — чтобы обычный подъём стенда её не трогал.
generator: generator:
@@ -378,6 +255,8 @@ services:
airflow-init: airflow-init:
<<: *airflow-common <<: *airflow-common
build:
context: ./infra/airflow
user: "0:0" user: "0:0"
restart: "no" restart: "no"
depends_on: depends_on:
@@ -385,14 +264,6 @@ services:
condition: service_healthy condition: service_healthy
clickhouse-01: clickhouse-01:
condition: service_healthy condition: service_healthy
# Не убирать: без зависимого успешный clickhouse-init считается
# упавшим для --wait.
clickhouse-init:
condition: service_completed_successfully
# По той же причине. Слепки идут за событиями и замыкают цепь разовых
# служб; долгоживущие службы данных её не ждут, а `airflow-init` ждёт.
world-snapshots:
condition: service_completed_successfully
entrypoint: ["/bin/bash"] entrypoint: ["/bin/bash"]
command: ["/opt/airflow/init.sh"] command: ["/opt/airflow/init.sh"]
@@ -420,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
@@ -433,10 +304,16 @@ services:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./dags:/opt/airflow/dags:ro - ./dags:/opt/airflow/dags:ro
- ./sql:/opt/airflow/sql:ro - ./sql:/opt/airflow/sql:ro
- ./generator:/opt/airflow/world/generator:ro
- ./data:/opt/airflow/world/data:ro
- ./.dockerignore:/opt/airflow/world/.dockerignore:ro
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro - ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
- airflow_logs:/opt/airflow/logs - airflow_logs:/opt/airflow/logs
- airflow_auth:/opt/airflow/auth - airflow_auth:/opt/airflow/auth
mem_limit: 640m # LocalExecutor запускает здесь до четырех процессов задач одновременно.
# 640 МиБ дали OOM, а 1 ГиБ - давление на memory.max при штатном dq_check;
# 1,5 ГиБ оставляет запас при AIRFLOW__CORE__PARALLELISM=4.
mem_limit: 1536m
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"] test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"]
interval: 10s interval: 10s
@@ -463,6 +340,8 @@ services:
superset-init: superset-init:
<<: *superset-common <<: *superset-common
build:
context: ./infra/superset
restart: "no" restart: "no"
healthcheck: healthcheck:
disable: true disable: true
+426
View File
@@ -0,0 +1,426 @@
"""Трансформации DDS: заказы, сессии и карта идентичностей.
Запросы рассчитывают заказы и сессии за выбранные дни и записывают результат
в промежуточные таблицы. Затем готовые дневные разделы этих таблиц заменяют
разделы основных таблиц. Пока идет расчет, читатели видят прежние данные.
Карта идентичностей пересчитывается целиком после заказов. Новые строки
добавляются с меткой времени сборки; представление с FINAL выбирает последнюю
версию каждой строки.
Даг запускается без расписания и параметров. Даты он выбирает по состоянию
таблиц и позиции мира. Новый запуск заново определяет диапазон: наличие даты
не подтверждает полноту дня и не гарантирует повтор его сборки после сбоя.
Подробности — docs/architecture/etl.md, «Объём прогона» и «Идемпотентность».
"""
from __future__ import annotations
import datetime
import logging
import os
import time
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.sdk import Variable, dag, task, task_group
# Пропуск приезжает из Task SDK: в airflow.exceptions он объявлен устаревшим
# и говорит об этом в журнал задачи (проверено на 3.3.0).
from airflow.sdk.exceptions import AirflowSkipException
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
SQL_ROOT = Path("/opt/airflow/sql")
CLICKHOUSE_CONNECTION = "clickhouse_default"
CLICKHOUSE_CLUSTER = "clickstream_cluster"
KAFKA_BOOTSTRAP_SERVERS = os.environ["KAFKA_BOOTSTRAP_SERVERS"]
HITS_TOPIC = os.environ["KAFKA_TOPIC"]
HITS_GROUP = "clickstream_hits"
HITS_PARTITIONS = (0, 1)
READINESS_TIMEOUT_SECONDS = 300
READINESS_POLL_SECONDS = 3
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставят
# даги модельного времени, и только она отличает прожитый день от живого — зачем это
# сессиям, разобрано в sql/dds/session_scope.sql.
WORLD_POSITION = "world_position"
def _kafka_readiness() -> tuple[bool, str]:
"""Проверить, дочитала ли группа оба раздела событий."""
from confluent_kafka import ConsumerGroupTopicPartitions, TopicPartition
from confluent_kafka.admin import AdminClient, OffsetSpec
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
groups = admin.list_consumer_groups(request_timeout=10).result()
if groups.errors:
raise RuntimeError(f"Kafka не вернула список групп: {groups.errors}")
group_exists = any(group.group_id == HITS_GROUP for group in groups.valid)
committed: dict[int, int] = {}
if group_exists:
request = ConsumerGroupTopicPartitions(
HITS_GROUP,
[TopicPartition(HITS_TOPIC, partition) for partition in HITS_PARTITIONS],
)
result = admin.list_consumer_group_offsets([request], request_timeout=10)[
HITS_GROUP
].result()
for topic_partition in result.topic_partitions:
if topic_partition.error is None and topic_partition.offset >= 0:
committed[topic_partition.partition] = topic_partition.offset
high_watermarks: dict[int, int] = {}
watermark_errors: dict[int, str] = {}
# OffsetSpec.latest возвращает следующее смещение после хвоста; сверено
# через Context7 и на установленном confluent-kafka 2.15.0.
latest = admin.list_offsets(
{
TopicPartition(HITS_TOPIC, partition): OffsetSpec.latest()
for partition in HITS_PARTITIONS
},
request_timeout=10,
)
for topic_partition, future in latest.items():
try:
offset = future.result().offset
if offset >= 0:
high_watermarks[topic_partition.partition] = offset
except Exception as error:
watermark_errors[topic_partition.partition] = str(error)
all_known = group_exists
total_lag = 0
parts: list[str] = []
for partition in HITS_PARTITIONS:
offset = committed.get(partition)
high = high_watermarks.get(partition)
lag = high - offset if offset is not None and high is not None else None
if lag is None or lag < 0:
all_known = False
else:
total_lag += lag
logging.info(
"Kafka %s[%s]: подтверждено=%s, верхнее смещение=%s, отставание=%s",
HITS_TOPIC,
partition,
offset,
high,
lag,
)
parts.append(
f"{HITS_TOPIC}[{partition}]: подтверждено={offset}, "
f"верхнее смещение={high}, отставание={lag}"
)
if partition in watermark_errors:
parts[-1] += (
f", ошибка чтения верхнего смещения={watermark_errors[partition]}"
)
if not group_exists:
logging.info("группа потребителей %s отсутствует", HITS_GROUP)
lag_text = str(total_lag) if all_known else "неизвестно"
logging.info("суммарное отставание Kafka: %s", lag_text)
detail = "; ".join(parts)
if not group_exists:
detail = f"группа {HITS_GROUP} отсутствует; {detail}"
return all_known and total_lag == 0, detail
def _delivery_queue_readiness() -> tuple[bool, str]:
"""Проверить, пуста ли очередь доставки событий на каждой ноде."""
query = f"""
SELECT
node,
sum(queued_files) AS files,
arrayStringConcat(
groupArrayIf(
last_exception,
queued_files > 0 AND notEmpty(last_exception)
),
'; '
) AS last_exception
FROM
(
-- У пустой очереди нет строк; system.one оставляет обе ноды
-- видимыми и в зеленом опросе.
SELECT
hostName() AS node,
toUInt64(0) AS queued_files,
'' AS last_exception
FROM clusterAllReplicas('{CLICKHOUSE_CLUSTER}', system.one)
UNION ALL
SELECT
hostName(),
data_files + broken_data_files AS queued_files,
last_exception
FROM clusterAllReplicas(
'{CLICKHOUSE_CLUSTER}', system.distribution_queue
)
WHERE database = 'ods' AND table = 'event_dist'
)
GROUP BY node
ORDER BY node
"""
rows = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION).get_records(query)
ready = True
nodes: list[str] = []
for node, files, last_exception in rows:
files = int(files)
ready = ready and files == 0
logging.info(
"очередь ods.event_dist на %s: файлов=%s, последняя ошибка=%s",
node,
files,
last_exception or "нет",
)
detail = f"{node}: файлов={files}"
if last_exception:
detail += f", последняя ошибка={last_exception}"
nodes.append(detail)
return ready, "; ".join(nodes)
def _wait_for_phase(
probe,
phase: str,
deadline: float,
) -> None:
"""Дождаться прохождения одного барьера до общего крайнего срока."""
last_observation = "первый опрос не выполнен"
last_error: str | None = None
while time.monotonic() < deadline:
try:
ready, last_observation = probe()
last_error = None
except Exception as error:
ready = False
last_error = str(error)
logging.warning("%s: ошибка опроса: %s", phase, last_error)
if ready:
return
remaining = deadline - time.monotonic()
if remaining > 0:
time.sleep(min(READINESS_POLL_SECONDS, remaining))
detail = last_observation
if last_error:
detail += f"; ошибка последнего опроса: {last_error}"
raise AirflowException(
f"общий срок ожидания готовности в {READINESS_TIMEOUT_SECONDS} с "
f'истек на этапе "{phase}": {detail}'
)
def _world_position() -> int:
"""Позиция мира числом, с внятным отказом на непригодном значении.
Ту же переменную читают даги времени, но общего модуля у дагов нет намеренно:
связь между дагами времени и трансформацией держит сама переменная, а не импорт.
"""
raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None:
raise AirflowException(
"мир ещё не создан: выполните make up и дождитесь world_initialize"
)
try:
position = int(raw_position)
except (TypeError, ValueError) as error:
raise AirflowException(
f"world_position={raw_position!r} не является номером дня; "
"выполните make rebuild-storage"
) from error
if position < 1:
raise AirflowException(
f"мир не готов: world_position={position}; "
"выполните make up или make rebuild-storage"
)
return position
def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
"""Дни отрезка строками — в таком виде их ждёт замена партиции."""
return [
(first_day + datetime.timedelta(days=shift)).isoformat()
for shift in range((last_day - first_day).days + 1)
]
@task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Подготовить параметры отдельной задачи replace для каждого дня.
expand_kwargs получает список словарей и создает по одному экземпляру
задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
"""
return [{"params": {"day": day}} for day in scope["days"]]
@dag(
dag_id="dds_transform",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
# Одновременные запуски используют одни промежуточные таблицы.
# Второй мог бы очистить результат первого до замены партиций.
max_active_runs=1,
template_searchpath=str(SQL_ROOT),
tags=["dds"],
)
def dds_transform():
"""Пересобрать сущности DDS по текущему состоянию источников."""
@task
def wait_for_source() -> None:
"""Дождаться чтения событий из Kafka и их доставки в ODS."""
deadline = time.monotonic() + READINESS_TIMEOUT_SECONDS
_wait_for_phase(_kafka_readiness, "Kafka", deadline)
_wait_for_phase(
_delivery_queue_readiness,
"очередь доставки ClickHouse",
deadline,
)
@task_group(group_id="order")
def order():
"""Заказ: изменяемые дни последнего слепка и пропущенные дни."""
@task
def scope() -> dict[str, object]:
"""Спросить у хранилища, какие дни собирает этот запуск."""
query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8")
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query)
# Без заказов ODS расчет невозможен: задача сообщает об ошибке.
if row is None:
raise AirflowException(
"в ods.order нет ни одного заказа: собирать нечего; "
"проверьте, дошёл ли слепок до хранилища"
)
first_day, last_day = row
days = _days(first_day, last_day)
logging.info(
"объём пересборки: %s … %s, дней %d",
first_day,
last_day,
len(days),
)
return {
"first_day": first_day.isoformat(),
"last_day": last_day.isoformat(),
"days": days,
}
days = scope()
rebuild = SQLExecuteQueryOperator(
task_id="rebuild",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/order_rebuild.sql",
parameters={
"load_id": "{{ run_id }}",
"first_day": days["first_day"],
"last_day": days["last_day"],
},
split_statements=True,
do_xcom_push=False,
)
# На каждый день создается отдельная задача замены партиции.
# Неудачную замену можно повторить, пока промежуточная таблица
# сохраняет рассчитанные строки этого запуска.
replace = SQLExecuteQueryOperator.partial(
task_id="replace",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/order_replace.sql",
do_xcom_push=False,
).expand_kwargs(replacements(days))
rebuild >> replace
@task_group(group_id="session")
def session():
"""Сессия: дни, которые мир уже прожил, а слой ещё не собрал."""
@task
def scope() -> dict[str, object]:
"""Спросить у хранилища и у позиции мира, какие дни собирает запуск."""
query = (SQL_ROOT / "dds" / "session_scope.sql").read_text(encoding="utf-8")
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query, parameters={"position": _world_position()})
if row is None:
raise AirflowException(
"в ods.event нет ни одного события: собирать нечего; "
"проверьте, дошёл ли поток до хранилища"
)
first_day, last_day = row
# Для каждого прожитого дня уже есть строки сессий.
# Новых дней для расчета нет: группа задач будет пропущена.
if first_day is None:
raise AirflowSkipException(
f"сессии собраны по {last_day} включительно, "
"новых прожитых дней нет"
)
days = _days(first_day, last_day)
logging.info(
"объём сборки: %s … %s, дней %d",
first_day,
last_day,
len(days),
)
return {
"first_day": first_day.isoformat(),
"last_day": last_day.isoformat(),
"days": days,
}
days = scope()
rebuild = SQLExecuteQueryOperator(
task_id="rebuild",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/session_rebuild.sql",
parameters={
"load_id": "{{ run_id }}",
"first_day": days["first_day"],
"last_day": days["last_day"],
},
split_statements=True,
do_xcom_push=False,
)
replace = SQLExecuteQueryOperator.partial(
task_id="replace",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/session_replace.sql",
do_xcom_push=False,
).expand_kwargs(replacements(days))
rebuild >> replace
source_ready = wait_for_source()
orders = order()
sessions = session()
source_ready >> [orders, sessions]
identity_map = SQLExecuteQueryOperator(
task_id="identity_map",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/identity_map_rebuild.sql",
parameters={"load_id": "{{ run_id }}"},
do_xcom_push=False,
)
orders >> identity_map
dds_transform()
+212
View File
@@ -0,0 +1,212 @@
"""Трансформации слоя DM: сборка стендовых витрин.
Даты для расчета выручки берутся из последнего слепка заказов и списка
пропущенных дней. Сверка покупок с заказами учитывает также прожитые дни
событий и дни, где заказ еще не найден. Запросы записывают результаты
в промежуточные таблицы, затем заменяют дневные разделы основных таблиц.
Трафик пересчитывается за всю историю: новая связь посетителя с пользователем
магазина меняет число известных пользователей и за прошлые дни.
"""
from __future__ import annotations
import datetime
import logging
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.sdk import Variable, dag, task, task_group
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
SQL_ROOT = Path("/opt/airflow/sql")
CLICKHOUSE_CONNECTION = "clickhouse_default"
# Та же ось модельного времени, что у DDS. Общего модуля у дагов нет
# намеренно: связь держит переменная Airflow, а не импорт одного дага другим.
WORLD_POSITION = "world_position"
def _world_position() -> int:
"""Позиция мира для границы прожитых событий сверки.
Причина отдельного чтения переменной разобрана у одноименной функции в
``dds_transform.py``.
"""
raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None:
raise AirflowException(
"мир еще не создан: выполните make up и дождитесь world_initialize"
)
try:
position = int(raw_position)
except (TypeError, ValueError) as error:
raise AirflowException(
f"world_position={raw_position!r} не является номером дня; "
"выполните make rebuild-storage"
) from error
if position < 1:
raise AirflowException(
f"мир не готов: world_position={position}; "
"выполните make up или make rebuild-storage"
)
return position
def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
"""Дни отрезка строками - в таком виде их ждет замена партиции."""
return [
(first_day + datetime.timedelta(days=shift)).isoformat()
for shift in range((last_day - first_day).days + 1)
]
@task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Подготовить параметры отдельной задачи replace для каждого дня.
expand_kwargs получает список словарей и создает по одному экземпляру
задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
"""
return [{"params": {"day": day}} for day in scope["days"]]
@dag(
dag_id="dm_transform",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
template_searchpath=str(SQL_ROOT),
tags=["dm"],
)
def dm_transform():
"""Пересобрать стендовые витрины по текущему состоянию DDS."""
@task_group(group_id="revenue_daily")
def revenue_daily():
"""Выручка: изменяемое окно заказов и пропущенная история."""
@task
def scope() -> dict[str, object]:
"""Спросить у хранилища, какие дни пересобирает запуск."""
query = (SQL_ROOT / "dm" / "revenue_daily_scope.sql").read_text(
encoding="utf-8"
)
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query)
if row is None:
raise AirflowException(
"нет слепка заказов в ODS или готовых заказов в DDS; "
"дождитесь загрузки слепка и сначала выполните dds_transform"
)
first_day, last_day = row
days = _days(first_day, last_day)
logging.info(
"объем пересборки: %s … %s, дней %d",
first_day,
last_day,
len(days),
)
return {
"first_day": first_day.isoformat(),
"last_day": last_day.isoformat(),
"days": days,
}
days = scope()
rebuild = SQLExecuteQueryOperator(
task_id="rebuild",
conn_id=CLICKHOUSE_CONNECTION,
sql="dm/revenue_daily_rebuild.sql",
parameters={
"load_id": "{{ run_id }}",
"first_day": days["first_day"],
"last_day": days["last_day"],
},
split_statements=True,
do_xcom_push=False,
)
replace = SQLExecuteQueryOperator.partial(
task_id="replace",
conn_id=CLICKHOUSE_CONNECTION,
sql="dm/revenue_daily_replace.sql",
do_xcom_push=False,
).expand_kwargs(replacements(days))
rebuild >> replace
revenue_daily()
@task_group(group_id="purchase_vs_orders")
def purchase_vs_orders():
"""Сверка: изменяемые заказы, прожитые покупки и дни ожидания заказа."""
@task
def scope() -> dict[str, object]:
"""Спросить у обоих источников, какие дни пересобрать."""
query = (SQL_ROOT / "dm" / "purchase_vs_orders_scope.sql").read_text(
encoding="utf-8"
)
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query, parameters={"position": _world_position()})
if row is None:
raise AirflowException(
"нет принятого слепка заказов или прожитых покупок; "
"дождитесь источников и сначала выполните dds_transform"
)
first_day, last_day = row
days = _days(first_day, last_day)
logging.info(
"объем пересборки: %s … %s, дней %d",
first_day,
last_day,
len(days),
)
return {
"first_day": first_day.isoformat(),
"last_day": last_day.isoformat(),
"days": days,
}
days = scope()
rebuild = SQLExecuteQueryOperator(
task_id="rebuild",
conn_id=CLICKHOUSE_CONNECTION,
sql="dm/purchase_vs_orders_rebuild.sql",
parameters={
"load_id": "{{ run_id }}",
"first_day": days["first_day"],
"last_day": days["last_day"],
},
split_statements=True,
do_xcom_push=False,
)
replace = SQLExecuteQueryOperator.partial(
task_id="replace",
conn_id=CLICKHOUSE_CONNECTION,
sql="dm/purchase_vs_orders_replace.sql",
do_xcom_push=False,
).expand_kwargs(replacements(days))
rebuild >> replace
purchase_vs_orders()
SQLExecuteQueryOperator(
task_id="daily_traffic",
conn_id=CLICKHOUSE_CONNECTION,
sql="dm/daily_traffic_rebuild.sql",
parameters={"load_id": "{{ run_id }}"},
do_xcom_push=False,
)
dm_transform()
+319
View File
@@ -0,0 +1,319 @@
"""Проверки качества стендовых моделей и витрин.
Пять проверок сначала собирают общий донор. После его полной сборки дневные
партиции публикуются одной серией замен, и только затем именованные задачи
утверждают результат. Расхождение поэтому остаётся в ``dm.dq_summary_v`` до
красного завершения дага; ошибка сборки прежнюю сводку не меняет.
"""
from __future__ import annotations
import datetime
import json
import logging
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.sdk import Variable, dag, get_current_context, task, task_group
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
SQL_ROOT = Path("/opt/airflow/sql")
INVENTORY_PATH = Path("/opt/airflow/world/data/world-inventory.json")
CLICKHOUSE_CONNECTION = "clickhouse_default"
WORLD_POSITION = "world_position"
DETAIL_LIMIT = 20
SUMMARY_COLUMNS = [
"data_date",
"check_name",
"reference_rows",
"actual_rows",
"failed_rows",
"status",
"_load_id",
"_load_ts",
]
def _clickhouse_hook() -> ClickHouseHook:
"""Подключение, которое дожидается записи в Distributed-таблицу."""
return ClickHouseHook(
clickhouse_conn_id=CLICKHOUSE_CONNECTION,
session_settings={"distributed_foreground_insert": 1},
)
def _world_position() -> int:
"""Первый несыгранный день оси мира."""
raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None:
raise AirflowException(
"мир еще не создан: выполните make up и дождитесь world_initialize"
)
try:
position = int(raw_position)
except (TypeError, ValueError) as error:
raise AirflowException(
f"world_position={raw_position!r} не является номером дня; "
"выполните make rebuild-storage"
) from error
if position < 1:
raise AirflowException(
f"мир не готов: world_position={position}; "
"выполните make up или make rebuild-storage"
)
return position
def _publish_summary(
check_name: str,
summaries: list[tuple[object, ...]],
) -> list[str]:
"""Записать дневную сводку и вернуть малый срез готовой диагностики."""
if not summaries:
raise AirflowException(f"{check_name}: область проверки пуста")
diagnostics: list[str] = []
context = get_current_context()
load_id = context["run_id"]
load_ts = datetime.datetime.now(datetime.UTC)
rows = []
for (
data_date,
reference_rows,
actual_rows,
failed_rows,
details,
) in summaries:
rows.append(
(
data_date,
check_name,
reference_rows,
actual_rows,
failed_rows,
"pass" if failed_rows == 0 else "fail",
load_id,
load_ts,
)
)
diagnostics.extend(details[: DETAIL_LIMIT - len(diagnostics)])
_clickhouse_hook().bulk_insert_rows(
"dm.dq_summary_stage_dist",
rows,
column_names=SUMMARY_COLUMNS,
)
return diagnostics
def _build_sql_check(
check_name: str,
sql_name: str,
parameters: dict[str, object] | None = None,
) -> list[str]:
"""Выполнить SQL-сверку и записать ее дневные итоги в донор."""
query = (SQL_ROOT / "dq" / sql_name).read_text(encoding="utf-8")
summaries = _clickhouse_hook().get_records(query, parameters=parameters)
return _publish_summary(check_name, summaries)
@task
def build_sql_check(check_name: str, sql_name: str) -> list[str]:
"""Собрать проверку, которой не нужны координаты модельного мира."""
return _build_sql_check(check_name, sql_name)
@task
def build_sessions_vs_reference() -> list[str]:
"""Сверить сессии только в пределах прожитых дней мира."""
return _build_sql_check(
"sessions_vs_reference",
"sessions_vs_reference.sql",
parameters={"position": _world_position()},
)
@task
def build_classes_vs_inventory() -> list[str]:
"""Сопоставить классы закрытых дней с техническим оракулом генератора."""
inventory = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
expected = {
(datetime.date.fromisoformat(day["date"]), mismatch_class): count
for day in inventory["days"]
if "orders" in day
for mismatch_class, count in day["orders"].items()
}
if not expected:
raise AirflowException("classes_vs_inventory: в паспорте нет закрытых дней")
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
# GROUP BY закономерно не возвращает группу из нуля строк.
actual = {key: 0 for key, count in expected.items() if count == 0}
actual.update(
{
(data_date, mismatch_class): count
for data_date, mismatch_class, count in _clickhouse_hook().get_records(
query
)
if (data_date, mismatch_class) in expected
}
)
summaries = []
for data_date in sorted({day for day, _ in expected}):
details = []
reference_rows = 0
actual_rows = 0
failed_rows = 0
for (day, mismatch_class), reference_count in sorted(expected.items()):
if day != data_date:
continue
actual_key = (day, mismatch_class)
actual_count = actual.get(actual_key)
reference_rows += 1
actual_rows += int(actual_key in actual)
if reference_count != actual_count:
failed_rows += 1
actual_text = actual_count if actual_count is not None else "нет ключа"
details.append(
f"{day} key={mismatch_class}: "
f"эталон={reference_count}, "
f"объект={actual_text}"
)
summaries.append((data_date, reference_rows, actual_rows, failed_rows, details))
return _publish_summary("classes_vs_inventory", summaries)
@task
def publication_scope() -> list[dict[str, dict[str, str]]]:
"""Вернуть дни полностью собранного донора для размноженной замены."""
rows = _clickhouse_hook().get_records(
"SELECT DISTINCT data_date FROM dm.dq_summary_stage_dist ORDER BY data_date"
)
if not rows:
raise AirflowException("донор dm.dq_summary пуст: публиковать нечего")
return [{"params": {"day": data_date.isoformat()}} for (data_date,) in rows]
@task
def drop_stale_partitions() -> None:
"""Убрать дни, исчезнувшие после починки с обеих сторон сверки."""
hook = _clickhouse_hook()
stale_days = hook.get_records(
"""
SELECT DISTINCT data_date
FROM dm.dq_summary_dist
WHERE data_date GLOBAL NOT IN
(SELECT data_date FROM dm.dq_summary_stage_dist)
ORDER BY data_date
"""
)
for (data_date,) in stale_days:
# ALTER PARTITION не принимает параметр ClickHouse. Значение пришло
# из колонки Date самого хранилища и форматируется без внешнего ввода.
hook.run(
"ALTER TABLE dm.dq_summary_rep ON CLUSTER clickstream_cluster "
f"DROP PARTITION '{data_date.isoformat()}'"
)
@task
def assert_check(check_name: str, diagnostics: list[str]) -> None:
"""Покрасить именованную задачу по уже опубликованной сводке."""
failed_days = _clickhouse_hook().get_records(
"""
SELECT data_date, reference_rows, actual_rows, failed_rows
FROM dm.dq_summary_v
WHERE check_name = {check_name:String} AND failed_rows > 0
ORDER BY data_date
""",
parameters={"check_name": check_name},
)
if not failed_days:
return
for detail in diagnostics:
logging.error("%s: %s", check_name, detail)
summary = "; ".join(
f"{data_date}: эталонных ключей {reference_rows}, "
f"фактических {actual_rows}, нарушено {failed_rows}"
for data_date, reference_rows, actual_rows, failed_rows in failed_days
)
raise AirflowException(f"{check_name}: {summary}")
@dag(
dag_id="dq_check",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
template_searchpath=str(SQL_ROOT),
tags=["dq"],
)
def dq_check():
"""Собрать, опубликовать и утвердить текущее качество данных."""
prepare = SQLExecuteQueryOperator(
task_id="prepare",
conn_id=CLICKHOUSE_CONNECTION,
sql="dq/prepare.sql",
do_xcom_push=False,
)
sessions = build_sessions_vs_reference()
classes = build_classes_vs_inventory.override(
task_id="build_classes_vs_inventory"
)()
revenue = build_sql_check.override(task_id="build_revenue_daily_vs_dds")(
"revenue_daily_vs_dds", "revenue_daily_vs_dds.sql"
)
purchases = build_sql_check.override(task_id="build_purchase_vs_orders_vs_dds")(
"purchase_vs_orders_vs_dds", "purchase_vs_orders_vs_dds.sql"
)
traffic = build_sql_check.override(task_id="build_daily_traffic_vs_dds")(
"daily_traffic_vs_dds", "daily_traffic_vs_dds.sql"
)
builders = [sessions, classes, revenue, purchases, traffic]
prepare >> builders
days = publication_scope()
for builder in builders:
builder >> days
replace = SQLExecuteQueryOperator.partial(
task_id="publish",
conn_id=CLICKHOUSE_CONNECTION,
sql="dq/replace.sql",
do_xcom_push=False,
).expand_kwargs(days)
sessions_assertion = assert_check.override(task_id="sessions_vs_reference")(
"sessions_vs_reference", sessions
)
classes_assertion = assert_check.override(task_id="classes_vs_inventory")(
"classes_vs_inventory", classes
)
@task_group(group_id="marts_vs_dds")
def assert_marts():
return [
assert_check.override(task_id="revenue_daily_vs_dds")(
"revenue_daily_vs_dds", revenue
),
assert_check.override(task_id="purchase_vs_orders_vs_dds")(
"purchase_vs_orders_vs_dds", purchases
),
assert_check.override(task_id="daily_traffic_vs_dds")(
"daily_traffic_vs_dds", traffic
),
]
assertions = [sessions_assertion, classes_assertion, *assert_marts()]
remove_stale = drop_stale_partitions()
replace >> remove_stale >> assertions
dq_check()
+53
View File
@@ -0,0 +1,53 @@
"""Дневной конвейер: DDS, DM и проверка качества.
Своих преобразований у дага нет. Ждущие триггеры связывают состояния:
если слой краснеет, конвейер не запускает следующий слой и краснеет сам.
"""
from __future__ import annotations
import datetime
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.sdk import dag
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
@dag(
dag_id="etl_pipeline",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
tags=["etl"],
)
def etl_pipeline():
"""Собрать слои последовательно по текущему состоянию данных."""
dds = TriggerDagRunOperator(
task_id="trigger_dds",
trigger_dag_id="dds_transform",
wait_for_completion=True,
poke_interval=10,
)
dm = TriggerDagRunOperator(
task_id="trigger_dm",
trigger_dag_id="dm_transform",
wait_for_completion=True,
poke_interval=10,
)
# Airflow 3.3: оператор провайдера standard с wait_for_completion=True
# поднимает AirflowException, если запущенный даг завершился красным.
# Поведение и путь импорта повторно сверены через Context7 27 августа
# 2026 года.
dq = TriggerDagRunOperator(
task_id="trigger_dq",
trigger_dag_id="dq_check",
wait_for_completion=True,
poke_interval=10,
)
dds >> dm >> dq
etl_pipeline()
+138
View File
@@ -0,0 +1,138 @@
"""Ручная самопроверка учебных витрин DM против их источников."""
from __future__ import annotations
import datetime
import logging
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.sdk import dag, task
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
SQL_ROOT = Path("/opt/airflow/sql/dq")
CLICKHOUSE_CONNECTION = "clickhouse_default"
DETAIL_LIMIT = 20
def _clickhouse_hook() -> ClickHouseHook:
"""Подключение к ClickHouse для чтения обеих сторон сверки."""
return ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
def _mart_exists(hook: ClickHouseHook, mart: str) -> bool:
"""Проверить наличие публичной витрины на ноде подключения."""
database, name = mart.split(".", maxsplit=1)
rows = hook.get_records(
"""
SELECT count()
FROM system.tables
WHERE database = {database:String} AND name = {name:String}
""",
parameters={"database": database, "name": name},
)
return rows[0][0] == 1
@task
def check_mart(
mart: str,
lab_path: str,
sql_name: str,
scope_sql_name: str | None = None,
) -> None:
"""Сверить одну домашнюю витрину с независимым ответом из источника."""
hook = _clickhouse_hook()
if not _mart_exists(hook, mart):
raise AirflowException(f"витрина {mart} не найдена; выполните ТЗ {lab_path}")
try:
query = (SQL_ROOT / sql_name).read_text(encoding="utf-8")
scope_query = (
(SQL_ROOT / scope_sql_name).read_text(encoding="utf-8")
if scope_sql_name is not None
else None
)
except OSError as error:
raise AirflowException(
f"стенд не может прочитать запрос проверки {sql_name}: {error}; "
"сообщите наставнику"
) from error
try:
parameter_sets = [None]
if scope_query is not None:
parameter_sets = [{"day": day} for (day,) in hook.get_records(scope_query)]
summaries = [
summary
for parameters in parameter_sets
for summary in hook.get_records(query, parameters=parameters)
]
except Exception as error:
raise AirflowException(
f"не удалось проверить витрину {mart}: {error}; "
f"сверьте договор колонок и проверку в ТЗ {lab_path}"
) from error
failed_rows = sum(summary[3] for summary in summaries)
if failed_rows == 0:
return
diagnostics = [detail for summary in summaries for detail in summary[4]][
:DETAIL_LIMIT
]
for detail in diagnostics:
logging.error("%s: %s", mart, detail)
days = "; ".join(
f"{data_date}: ключей источника {reference_rows}, "
f"ключей витрины {actual_rows}, расходятся {day_failed_rows}"
for data_date, reference_rows, actual_rows, day_failed_rows, _ in summaries
if day_failed_rows > 0
)
compared_sides = diagnostics[0]
raise AirflowException(
f"витрина {mart} расходится с источником: {days}. "
f"Пример сравнения: {compared_sides}. Сверьтесь с ТЗ {lab_path}"
)
@dag(
dag_id="homework_check",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
tags=["dm", "labs"],
)
def homework_check():
"""Дать отдельный результат самопроверки по каждой домашней витрине."""
check_mart.override(task_id="events_enriched")(
"dm.events_enriched_v",
"docs/labs/1-1-events-enriched.md",
"events_enriched_vs_dds.sql",
"events_enriched_scope.sql",
)
check_mart.override(task_id="top_pages_daily")(
"dm.top_pages_daily_v",
"docs/labs/1-2-top-pages-daily.md",
"top_pages_daily_vs_dds.sql",
)
check_mart.override(task_id="session_overview")(
"dm.session_overview_v",
"docs/labs/1-3-session-overview.md",
"session_overview_vs_dds.sql",
)
check_mart.override(task_id="dq_errors_daily")(
"dm.dq_errors_daily_v",
"docs/labs/1-4-dq-errors-daily.md",
"dq_errors_daily_vs_ods.sql",
)
check_mart.override(task_id="utm_effectiveness")(
"dm.utm_effectiveness_v",
"docs/labs/1-5-utm-effectiveness.md",
"utm_effectiveness_vs_dds.sql",
)
homework_check()
+22 -64
View File
@@ -1,9 +1,9 @@
"""Приём заказов: пакетный забор слепка из Kafka в хранилище. """Приём заказов: пакетный забор слепка из Kafka в хранилище.
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор
порции в сырьё, затем разбор того же среза в типизированный ODS и в таблицу порции в `stg`, затем разбор того же среза в типизированный ODS и в таблицу
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, — брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, —
работник пульта мира после проигрыша дня, — и ждёт конца. шаговый даг модельного времени после проигрыша дня, — и ждёт конца.
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда Контраст с приёмом событий и есть урок. События тянет матвью, навсегда
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов
@@ -18,9 +18,9 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import logging
from airflow.sdk import Connection, dag, get_current_context, task from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
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)
@@ -43,67 +43,25 @@ SQL_ROOT = "/opt/airflow/sql"
) )
def orders_ingest(): def orders_ingest():
"""Забрать приехавший слепок заказов и разложить его по слоям.""" """Забрать приехавший слепок заказов и разложить его по слоям."""
pull_batch = SQLExecuteQueryOperator(
def clickhouse_client(): task_id="pull_batch",
# Импорт при выполнении задачи, а не при разборе файла: обработчик DAG conn_id="clickhouse_default",
# разбирает его снова и снова, и импорт наверху оплачивался бы каждым sql="stg/orders_raw_load.sql",
# разбором. parameters={"load_id": "{{ run_id }}"},
import clickhouse_connect do_xcom_push=False,
connection = Connection.get("clickhouse_default")
return clickhouse_connect.get_client(
host=connection.host,
port=connection.port,
username=connection.login,
password=connection.password,
database=connection.schema or "default",
connect_timeout=5,
send_receive_timeout=30,
)
# Аргумент с расширением из templates_exts Airflow подменяет текстом файла:
# берёт его из template_searchpath и прогоняет через Jinja при исполнении
# задачи, а не при разборе дага. В задачу приезжает готовый запрос — вместе
# с тем, что файл подключил через include.
@task(templates_exts=(".sql",))
def pull_batch(sql: str) -> None:
load_id = get_current_context()["run_id"]
client = clickhouse_client()
try:
summary = client.command(sql, parameters={"load_id": load_id})
finally:
client.close()
# Размер порции — read_rows: written_rows у вставки в Distributed
# считает не приехавшее.
logging.info(
"порция принята: строк %s, _load_id %s",
summary.summary["read_rows"],
load_id,
)
@task(templates_exts=(".sql",))
def parse_batch(good_rows_sql: str, bad_rows_sql: str) -> None:
"""Разобрать срез сырья в версии заказов и в брак.
Обе вставки в одном task_id: транзакции между ними ClickHouse не даёт,
а повтор задачи безопасен — срез читается по тому же неизменному
_load_id (docs/architecture/orders/ingestion.md, «Поток данных»).
"""
load_id = get_current_context()["run_id"]
client = clickhouse_client()
try:
client.command(good_rows_sql, parameters={"load_id": load_id})
client.command(bad_rows_sql, parameters={"load_id": load_id})
finally:
client.close()
# Счётчиков строк нет: у запроса с WHERE read_rows считает прочитанное
# с диска, а не подошедшее (storage.md, «Что проверено»).
logging.info("срез разобран: _load_id %s", load_id)
pull_batch("stg/orders_raw_load.sql") >> parse_batch(
"ods/order_load.sql",
"ods/order_errors_load.sql",
) )
parse_batch = SQLExecuteQueryOperator(
task_id="parse_batch",
conn_id="clickhouse_default",
sql="ods/order_parse.sql",
parameters={"load_id": "{{ run_id }}"},
split_statements=True,
do_xcom_push=False,
)
# ClickHouse не даёт транзакции между двумя вставками parse_batch. Один
# task_id сохраняет их порядок и одну точку повтора для неизменного load_id.
pull_batch >> parse_batch
orders_ingest() orders_ingest()
+88 -116
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import datetime import datetime
import uuid import uuid
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.sdk import Connection, dag, get_current_context, task from airflow.sdk import Connection, dag, get_current_context, task
CLUSTER = "clickstream_cluster" CLUSTER = "clickstream_cluster"
@@ -54,27 +55,12 @@ def _remote_parameters() -> dict[str, str]:
} }
def _clickhouse_client(): def _clickhouse_hook() -> ClickHouseHook:
# clickhouse_connect импортируется внутри функции, а не наверху файла: return ClickHouseHook(clickhouse_conn_id="clickhouse_default")
# обработчик DAG разбирает этот файл снова и снова, и импорт наверху
# оплачивался бы каждым разбором. Тяжёлые импорты Airflow советует
# держать внутри задач.
import clickhouse_connect
connection = Connection.get("clickhouse_default")
return clickhouse_connect.get_client(
host=connection.host,
port=connection.port,
username=connection.login,
password=connection.password,
database=connection.schema or "default",
connect_timeout=5,
send_receive_timeout=30,
)
def _table_engines(client, source: str) -> list[tuple[str, str]]: def _table_engines(hook: ClickHouseHook, source: str) -> list[tuple[str, str]]:
result = client.query( return hook.get_records(
f""" f"""
SELECT name, engine SELECT name, engine
FROM {source} FROM {source}
@@ -84,23 +70,23 @@ def _table_engines(client, source: str) -> list[tuple[str, str]]:
""", """,
parameters=_remote_parameters(), parameters=_remote_parameters(),
) )
return result.result_rows
def _drop_tables(client) -> None: def _drop_tables(hook: ClickHouseHook) -> None:
client.command( hook.run(
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER} SYNC" [
) f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} "
client.command( f"ON CLUSTER {CLUSTER} SYNC",
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC" f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC",
],
) )
# Отсутствие таблиц проверяют обе задачи — перед созданием и после уборки, # Отсутствие таблиц проверяют обе задачи — перед созданием и после уборки,
# — поэтому у этой проверки своё имя, а остальные живут прямо в теле задач. # — поэтому у этой проверки своё имя, а остальные живут прямо в теле задач.
def _assert_tables_absent(client) -> None: def _assert_tables_absent(hook: ClickHouseHook) -> None:
for node_name, source in NODES: for node_name, source in NODES:
remaining = _table_engines(client, source) remaining = _table_engines(hook, source)
if remaining: if remaining:
raise RuntimeError( raise RuntimeError(
f"служебные таблицы остались на {node_name}: {remaining}" f"служебные таблицы остались на {node_name}: {remaining}"
@@ -118,11 +104,11 @@ def _assert_tables_absent(client) -> None:
def test_clickhouse(): def test_clickhouse():
@task @task
def prepare_tables() -> None: def prepare_tables() -> None:
client = _clickhouse_client() hook = _clickhouse_hook()
try: _drop_tables(hook)
_drop_tables(client) _assert_tables_absent(hook)
_assert_tables_absent(client) hook.run(
client.command( [
f""" f"""
CREATE TABLE default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} CREATE TABLE default.{LOCAL_TABLE} ON CLUSTER {CLUSTER}
( (
@@ -133,9 +119,7 @@ def test_clickhouse():
'{{replica}}' '{{replica}}'
) )
ORDER BY marker ORDER BY marker
""" """,
)
client.command(
f""" f"""
CREATE TABLE default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER} CREATE TABLE default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER}
AS default.{LOCAL_TABLE} AS default.{LOCAL_TABLE}
@@ -145,86 +129,77 @@ def test_clickhouse():
'{LOCAL_TABLE}', '{LOCAL_TABLE}',
cityHash64(marker) cityHash64(marker)
) )
""" """,
) ],
for node_name, source in NODES: )
actual_tables = _table_engines(client, source) for node_name, source in NODES:
if actual_tables != EXPECTED_TABLES: actual_tables = _table_engines(hook, source)
raise RuntimeError( if actual_tables != EXPECTED_TABLES:
f"неверный набор таблиц на {node_name}: {actual_tables}" raise RuntimeError(
) f"неверный набор таблиц на {node_name}: {actual_tables}"
finally: )
client.close()
@task @task
def write_marker() -> dict[str, str]: def write_marker() -> dict[str, str]:
client = _clickhouse_client() hook = _clickhouse_hook()
try: marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}" hook.bulk_insert_rows(
client.insert( f"default.{LOCAL_TABLE}",
f"default.{LOCAL_TABLE}", [[marker]],
[[marker]], column_names=["marker"],
column_names=["marker"], )
) local_rows = hook.get_records(
local_rows = client.query( f"""
f""" SELECT hostName(), marker
SELECT hostName(), marker FROM default.{LOCAL_TABLE}
FROM default.{LOCAL_TABLE} WHERE marker = {{marker:String}}
WHERE marker = {{marker:String}} """,
""", parameters={"marker": marker},
parameters={"marker": marker}, )
).result_rows if len(local_rows) != 1 or local_rows[0][1] != marker:
if len(local_rows) != 1 or local_rows[0][1] != marker: raise RuntimeError(f"маркер не найден в локальной таблице: {marker}")
raise RuntimeError(f"маркер не найден в локальной таблице: {marker}") return {"marker": marker, "hostname": local_rows[0][0]}
return {"marker": marker, "hostname": local_rows[0][0]}
finally:
client.close()
@task @task
def read_from_node_2(written: dict[str, str]) -> None: def read_from_node_2(written: dict[str, str]) -> None:
client = _clickhouse_client() hook = _clickhouse_hook()
try: node_2_rows = hook.get_records(
node_2_rows = client.query( """
""" SELECT hostName()
SELECT hostName() FROM remote(
FROM remote( 'clickhouse-02:9000', 'system', 'one',
'clickhouse-02:9000', 'system', 'one', {remote_user:String}, {remote_password:String}
{remote_user:String}, {remote_password:String} )
) """,
""", parameters=_remote_parameters(),
parameters=_remote_parameters(), )
).result_rows if len(node_2_rows) != 1:
if len(node_2_rows) != 1: raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}")
raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}") distributed_rows = hook.get_records(
distributed_rows = client.query( f"""
f""" SELECT _shard_num, hostName(), marker
SELECT _shard_num, hostName(), marker FROM remote(
FROM remote( 'clickhouse-02:9000',
'clickhouse-02:9000', 'default',
'default', '{DISTRIBUTED_TABLE}',
'{DISTRIBUTED_TABLE}', {{remote_user:String}},
{{remote_user:String}}, {{remote_password:String}}
{{remote_password:String}} )
) WHERE marker = {{marker:String}}
WHERE marker = {{marker:String}} """,
""", parameters={
parameters={ "marker": written["marker"],
"marker": written["marker"], **_remote_parameters(),
**_remote_parameters(), },
}, )
).result_rows if written["hostname"] == node_2_rows[0][0]:
if written["hostname"] == node_2_rows[0][0]: raise RuntimeError("запись и чтение маркера должны идти с разных нод")
raise RuntimeError( expected_rows = [(1, written["hostname"], written["marker"])]
"запись и чтение маркера должны выполняться с разных нод" if distributed_rows != expected_rows:
) raise RuntimeError(
expected_rows = [(1, written["hostname"], written["marker"])] "нода 2 не прочитала маркер первого шарда через Distributed: "
if distributed_rows != expected_rows: f"{written['marker']}, получено {distributed_rows}"
raise RuntimeError( )
"нода 2 не прочитала маркер первого шарда через Distributed: "
f"{written['marker']}, получено {distributed_rows}"
)
finally:
client.close()
# Уборка идёт только после успеха: упавший пробник оставляет кластер таким, # Уборка идёт только после успеха: упавший пробник оставляет кластер таким,
# каким сломался, а остатки сносит начало следующего запуска. Правило # каким сломался, а остатки сносит начало следующего запуска. Правило
@@ -233,12 +208,9 @@ def test_clickhouse():
# с упавшей проверкой (ADR 0003). # с упавшей проверкой (ADR 0003).
@task @task
def cleanup_tables() -> None: def cleanup_tables() -> None:
client = _clickhouse_client() hook = _clickhouse_hook()
try: _drop_tables(hook)
_drop_tables(client) _assert_tables_absent(hook)
_assert_tables_absent(client)
finally:
client.close()
prepared = prepare_tables() prepared = prepare_tables()
written = write_marker() written = write_marker()
+68 -18
View File
@@ -1,22 +1,25 @@
"""Пульт мира: даги, которыми двигают ось модельного времени. """Даги модельного времени: ими двигают ось мира.
Их три, и они двух родов. **Работники** играют день и запускаются руками: Их три, и они двух родов. **Шаговые даги** играют день и запускаются руками:
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` — `world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
один день в темпе модельного времени. **Выключатель** `world_live` своей работы один день в темпе модельного времени. **Выключатель** `world_live` своей работы
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь не делает: он тикает по расписанию и дёргает шаговый даг живого дня, дожидаясь
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
границе модельных суток. границе модельных суток.
Сыграть день — половина работы. Вторая половина: отправить слепок заказов и Сыграть день — половина работы. Вторая половина: отправить слепок заказов и
дождаться, пока хранилище его заберёт. Поэтому у обоих работников за проигрышем дождаться, пока хранилище его заберёт. Поэтому у обоих шаговых дагов за проигрышем
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
`orders_ingest`. `orders_ingest`.
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник только разгоняет источники: слои после нее менти догоняет ручным запуском.
Разделение не косметическое. Расписание на самом шаговом даге заставило бы кнопку
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а шаговый даг
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день. при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник Календарь Airflow к оси мира отношения не имеет: какой день играть, шаговый даг
спрашивает у переменной, а не у логической даты прогона. спрашивает у переменной, а не у логической даты прогона.
Решения и доводы целиком — ADR 0009. Решения и доводы целиком — ADR 0009.
@@ -25,8 +28,10 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import logging
import os import os
from airflow.exceptions import AirflowException
from airflow.providers.docker.operators.docker import DockerOperator from airflow.providers.docker.operators.docker import DockerOperator
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.sdk import Param, Variable, dag, get_current_context, task from airflow.sdk import Param, Variable, dag, get_current_context, task
@@ -45,8 +50,8 @@ GENERATOR_ENVIRONMENT = {
ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"] 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.
# #
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее # Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
# при таком правиле — сыгранный дважды день, а повтор схлопнет # при таком правиле — сыгранный дважды день, а повтор схлопнет
@@ -58,18 +63,35 @@ 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
def first_unplayed_day() -> int: def first_unplayed_day() -> int:
"""Номер дня, с которого играть.""" """Номер дня, с которого играть."""
return int(Variable.get(WORLD_POSITION, default=STARTING_DAYS)) raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None:
raise AirflowException(
"мир ещё не создан: выполните make up и дождитесь world_initialize"
)
try:
position = int(raw_position)
except (TypeError, ValueError) as error:
raise AirflowException(
f"world_position={raw_position!r} не является номером дня; "
"выполните make rebuild-storage"
) from error
if position < STARTING_DAYS:
action = (
"выполните make up" if position == 0 else "выполните make rebuild-storage"
)
raise AirflowException(f"мир не готов: world_position={position}; {action}")
return position
def _generator(task_id: str, command: list[str]) -> DockerOperator: def _generator(task_id: str, command: list[str]) -> DockerOperator:
@@ -98,7 +120,7 @@ def _generator(task_id: str, command: list[str]) -> DockerOperator:
def _ingest_orders() -> TriggerDagRunOperator: def _ingest_orders() -> TriggerDagRunOperator:
"""Задача забора: дёрнуть даг приёма и дождаться, чем он кончился. """Задача забора: дёрнуть даг приёма и дождаться, чем он кончился.
Ожидание здесь несущее. Без него работник позеленел бы, не узнав, доехал Ожидание здесь несущее. Без него шаговый даг позеленел бы, не узнав, доехал
ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа
не должен переживать отказ выше (ADR 0003). не должен переживать отказ выше (ADR 0003).
""" """
@@ -106,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,
) )
@@ -120,7 +142,19 @@ def _ingest_orders() -> TriggerDagRunOperator:
tags=TAGS, tags=TAGS,
params={ params={
"days": Param( "days": Param(
1, type="integer", minimum=1, maximum=7, title="Сколько дней прожить" 1,
type="integer",
minimum=1,
maximum=7,
title="Сколько дней прожить",
# description_md рендерится подсказкой прямо в форме запуска
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
description_md=(
"Форма задает только число дней. Номер первого несыгранного "
"дня шаговый даг берёт из переменной `world_position` (Admin → "
"Variables). Поля Logical date и Run ID нужны самому Airflow "
"и на выбор дня не влияют."
),
) )
}, },
) )
@@ -128,14 +162,20 @@ def world_next_day():
"""Прожить следующие дни пачкой, без пауз. """Прожить следующие дни пачкой, без пауз.
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
нажимом, а не десятью: сколько дней играть — параметр запуска. нажимом, а не десятью: сколько дней играть — параметр запуска. Первый
день шаговый даг берёт из `world_position`. Логическая дата прогона на выбор
дня не влияет.
""" """
@task @task
def remember_played(first_day: int) -> None: def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранным дням и только по успеху.""" """Позиция ставится по сыгранным дням и только по успеху."""
days = get_current_context()["params"]["days"] days = get_current_context()["params"]["days"]
Variable.set(WORLD_POSITION, str(first_day + days)) position = first_day + days
Variable.set(WORLD_POSITION, str(position))
logging.info(
"дни %s–%s сыграны; новая позиция — %s", first_day, position - 1, position
)
first_day = first_unplayed_day() first_day = first_unplayed_day()
played = _generator( played = _generator(
@@ -175,12 +215,15 @@ def world_live_day():
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
реальные минуты, и суточная волна разворачивается на глазах. Запускается реальные минуты, и суточная волна разворачивается на глазах. Запускается
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`. руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
Следующий день шаговый даг берёт из `world_position`. Логическая дата прогона
на выбор дня не влияет.
""" """
@task @task
def remember_played(first_day: int) -> None: def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранному дню и только по успеху.""" """Позиция ставится по сыгранному дню и только по успеху."""
Variable.set(WORLD_POSITION, str(first_day + 1)) Variable.set(WORLD_POSITION, str(first_day + 1))
logging.info("сыгран день %s; новая позиция — %s", first_day, first_day + 1)
first_day = first_unplayed_day() first_day = first_unplayed_day()
played = _generator("play_day", ["live", "--day", PLAYED_DAY]) played = _generator("play_day", ["live", "--day", PLAYED_DAY])
@@ -191,7 +234,14 @@ def world_live_day():
["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC], ["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC],
) )
first_day >> played >> sent >> _ingest_orders() >> remember_played(first_day) position = remember_played(first_day)
etl = TriggerDagRunOperator(
task_id="trigger_etl_pipeline",
trigger_dag_id="etl_pipeline",
wait_for_completion=True,
poke_interval=10,
)
first_day >> played >> sent >> _ingest_orders() >> position >> etl
@dag( @dag(
+377
View File
@@ -0,0 +1,377 @@
"""Создание и пересоздание прикладного мира.
Compose поднимает службы, а этот граф создаёт всё прикладное поверх них:
топики, схему ClickHouse, данные стартовых дней, позицию модельного времени и
готовые слои. `world_initialize` безопасен для обычного `make up`;
`world_recreate` — явная операция обслуживания, которая сначала объявляет
прежний мир недействительным.
Контракт состояний и доводы — ADR 0013 и спека жизненного цикла мира.
"""
from __future__ import annotations
import datetime
import json
import logging
import os
import time
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.providers.docker.hooks.docker import DockerHook
from airflow.providers.docker.operators.docker import DockerOperator
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.sdk import Variable, chain, dag, task
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
TAGS = ["жизненный цикл мира"]
WORLD_POSITION = "world_position"
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
HITS_TOPIC = os.environ["KAFKA_TOPIC"]
ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
KAFKA_BOOTSTRAP_SERVERS = os.environ["KAFKA_BOOTSTRAP_SERVERS"]
GENERATOR_IMAGE = os.environ["GENERATOR_IMAGE"]
STAND_NETWORK = os.environ["STAND_NETWORK"]
SQL_ROOT = Path("/opt/airflow/sql")
DDL_ROOT = SQL_ROOT / "ddl"
WORLD_ROOT = Path("/opt/airflow/world")
INVENTORY = WORLD_ROOT / "data/world-inventory.json"
CLICKHOUSE_CONNECTION = "clickhouse_lifecycle"
CLICKHOUSE_CLUSTER = "clickstream_cluster"
APPLICATION_DATABASES = ("stg", "dm", "dds", "ods", "dic")
CONSUMER_GROUPS = ("clickstream_hits", "clickstream_orders")
TOPICS = {HITS_TOPIC: 2, ORDERS_TOPIC: 1}
def _clickhouse_hook() -> ClickHouseHook:
"""Подключить владельца жизненного цикла."""
return ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
def _generator(task_id: str, command: list[str]) -> DockerOperator:
"""Запустить канонический образ генератора в сети стенда."""
return DockerOperator(
task_id=task_id,
image=GENERATOR_IMAGE,
command=command,
network_mode=STAND_NETWORK,
environment={
"KAFKA_BOOTSTRAP_SERVERS": KAFKA_BOOTSTRAP_SERVERS,
"KAFKA_TOPIC": HITS_TOPIC,
},
# Вывод уже переехал в журнал задачи; завершённый контейнер не нужен.
auto_remove="force",
# Временный путь Airflow существует внутри контейнера, но монтировать
# его пытается хостовый Docker. Генератору этот каталог не нужен.
mount_tmp_dir=False,
)
def _kafka_error_code(error: Exception) -> int:
"""Код ошибки из KafkaException."""
return error.args[0].code()
@dag(
dag_id="world_initialize",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
template_searchpath=str(SQL_ROOT),
tags=TAGS,
)
def world_initialize():
"""Создать стартовый мир, если готового мира ещё нет."""
@task.branch
def choose_path() -> str:
raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None:
logging.info("world_position отсутствует: создаём первый мир")
return "create_topics"
try:
position = int(raw_position)
except (TypeError, ValueError) as error:
raise AirflowException(
f"world_position={raw_position!r} не является номером дня; "
"выполните make rebuild-storage"
) from error
if position == 0:
logging.info("очистка завершена: создаём мир заново")
return "create_topics"
if position == -1:
raise AirflowException(
"world_position=-1: очистка мира не завершена; "
"повторите make rebuild-storage"
)
if position >= STARTING_DAYS:
logging.info("мир уже существует: world_position=%s", position)
return "world_already_exists"
raise AirflowException(
f"world_position={position}: недопустимое незавершённое состояние; "
"выполните make rebuild-storage"
)
@task
def world_already_exists() -> None:
"""Читаемый зелёный конец ветки без изменений."""
logging.info("инициализация не требуется: готовый мир не изменён")
@task
def create_topics() -> None:
"""Создать топики до появления читающих их таблиц ClickHouse."""
from confluent_kafka import KafkaError, KafkaException
from confluent_kafka.admin import AdminClient, NewTopic
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
futures = admin.create_topics(
[
NewTopic(name, num_partitions=partitions, replication_factor=1)
for name, partitions in TOPICS.items()
],
request_timeout=30,
operation_timeout=15,
)
for name, future in futures.items():
try:
future.result()
logging.info("топик %s создан", name)
except KafkaException as error:
if _kafka_error_code(error) != KafkaError.TOPIC_ALREADY_EXISTS:
raise
logging.info("топик %s уже существует", name)
metadata = admin.list_topics(timeout=15)
for name, expected_partitions in TOPICS.items():
topic = metadata.topics.get(name)
actual_partitions = (
len(topic.partitions) if topic and not topic.error else 0
)
if actual_partitions != expected_partitions:
raise AirflowException(
f"у топика {name} разделов {actual_partitions}, "
f"ожидалось {expected_partitions}; "
"выполните make rebuild-storage"
)
@task
def build_generator_image() -> None:
"""Собрать генератор из текущего дерева, а не из старого образа."""
client = DockerHook(
docker_conn_id=None,
base_url="unix://var/run/docker.sock",
timeout=900,
).get_conn()
output = client.build(
path=str(WORLD_ROOT),
dockerfile="generator/Dockerfile",
tag=GENERATOR_IMAGE,
rm=True,
decode=True,
)
for item in output:
if error := item.get("error"):
raise AirflowException(
f"сборка генератора завершилась ошибкой: {error}"
)
if stream := item.get("stream", "").strip():
logging.info("docker build: %s", stream)
@task
def wait_for_events() -> None:
"""Дождаться приёма всех событий паспорта, включая строки брака."""
inventory = json.loads(INVENTORY.read_text(encoding="utf-8"))
expected = sum(day["events"] for day in inventory["days"])
first_date = inventory["days"][0]["date"]
last_date = inventory["days"][-1]["date"]
query = f"""
SELECT
(SELECT count() FROM ods.event_dist
WHERE EventDate BETWEEN '{first_date}' AND '{last_date}')
+ (SELECT count() FROM ods.event_errors_dist)
"""
hook = _clickhouse_hook()
arrived = 0
for attempt in range(1, 101):
row = hook.get_first(query)
if row is None:
raise AirflowException("ClickHouse не вернул счётчик приёма")
arrived = int(row[0])
if arrived >= expected:
logging.info("стартовый мир принят: строк %s", arrived)
return
if attempt % 5 == 0:
logging.info("принято %s из %s строк", arrived, expected)
time.sleep(3)
raise AirflowException(
f"стартовый мир не принят за 300 с: строк {arrived} из {expected}; "
"проверьте журнал send_initial_events и "
"SELECT * FROM system.kafka_consumers"
)
@task
def remember_starting_position() -> None:
"""Объявить мир готовым только после приёма обоих источников."""
Variable.set(WORLD_POSITION, str(STARTING_DAYS))
logging.info("мир готов: world_position=%s", STARTING_DAYS)
route = choose_path()
ready = world_already_exists()
topics = create_topics()
ddl_files = sorted(DDL_ROOT.glob("*.sql"))
if not ddl_files:
raise AirflowException(f"в {DDL_ROOT} нет файлов DDL")
ddl_tasks = [
SQLExecuteQueryOperator(
task_id=f"apply_{path.stem.replace('-', '_')}",
conn_id=CLICKHOUSE_CONNECTION,
sql=f"ddl/{path.name}",
split_statements=True,
do_xcom_push=False,
)
for path in ddl_files
]
image = build_generator_image()
events = _generator(
"send_initial_events",
["batch", "--day", "0", "--days", str(STARTING_DAYS)],
)
arrived = wait_for_events()
snapshots = _generator(
"send_initial_snapshots",
[
"snapshot",
"--day",
"0",
"--days",
str(STARTING_DAYS),
"--topic",
ORDERS_TOPIC,
],
)
orders = TriggerDagRunOperator(
task_id="trigger_orders_ingest",
trigger_dag_id="orders_ingest",
wait_for_completion=True,
poke_interval=10,
)
position = remember_starting_position()
etl = TriggerDagRunOperator(
task_id="trigger_etl_pipeline",
trigger_dag_id="etl_pipeline",
wait_for_completion=True,
poke_interval=10,
)
route >> [ready, topics]
chain(topics, *ddl_tasks, image, events, arrived, snapshots, orders, position, etl)
@dag(
dag_id="world_recreate",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
tags=TAGS,
)
def world_recreate():
"""Удалить только прикладной мир и делегировать его создание."""
@task
def invalidate_world() -> None:
Variable.set(WORLD_POSITION, "-1")
logging.info("прежний мир недействителен: world_position=-1")
@task
def drop_application_databases() -> None:
hook = _clickhouse_hook()
for database in APPLICATION_DATABASES:
logging.info("удаляем базу %s", database)
hook.run(
f"DROP DATABASE IF EXISTS {database} "
f"ON CLUSTER {CLICKHOUSE_CLUSTER} SYNC"
)
@task
def delete_consumer_groups() -> None:
from confluent_kafka import KafkaError, KafkaException
from confluent_kafka.admin import AdminClient
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
remaining = set(CONSUMER_GROUPS)
for attempt in range(1, 31):
futures = admin.delete_consumer_groups(
sorted(remaining), request_timeout=15
)
for group, future in futures.items():
try:
future.result()
remaining.discard(group)
logging.info("группа %s удалена", group)
except KafkaException as error:
code = _kafka_error_code(error)
if code == KafkaError.GROUP_ID_NOT_FOUND:
remaining.discard(group)
elif code != KafkaError.NON_EMPTY_GROUP:
raise
if not remaining:
return
if attempt % 5 == 0:
logging.info("ждём выхода потребителей из групп: %s", sorted(remaining))
time.sleep(2)
raise AirflowException(
f"за 60 с не освободились группы потребителей: {sorted(remaining)}"
)
@task
def delete_topics() -> None:
from confluent_kafka import KafkaError, KafkaException
from confluent_kafka.admin import AdminClient
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
futures = admin.delete_topics(list(TOPICS), request_timeout=30)
for name, future in futures.items():
try:
future.result()
logging.info("топик %s удалён", name)
except KafkaException as error:
if _kafka_error_code(error) != KafkaError.UNKNOWN_TOPIC_OR_PART:
raise
for _ in range(30):
existing = set(admin.list_topics(timeout=10).topics) & set(TOPICS)
if not existing:
return
time.sleep(1)
raise AirflowException(f"топики не исчезли за 30 с: {sorted(existing)}")
@task
def mark_world_clean() -> None:
Variable.set(WORLD_POSITION, "0")
logging.info("очистка завершена: world_position=0")
invalid = invalidate_world()
databases = drop_application_databases()
groups = delete_consumer_groups()
topics = delete_topics()
clean = mark_world_clean()
initialize = TriggerDagRunOperator(
task_id="trigger_world_initialize",
trigger_dag_id="world_initialize",
wait_for_completion=True,
poke_interval=10,
)
invalid >> databases >> groups >> topics >> clean >> initialize
world_initialize()
world_recreate()
+29 -22
View File
@@ -6,87 +6,94 @@
{ {
"day": 0, "day": 0,
"date": "2026-06-01", "date": "2026-06-01",
"events": 50626, "events": 50628,
"sha256": "81ad7f402bdd4c8b56425f6439a024b9996e5ca95dd207d444290d20a70ed407" "sha256": "d09704ed99e45824e451e2f6bbbbd5c547df382f7a582699955010f3807eb0ee",
"orders": {
"match": 192,
"cancelled": 20,
"lost_event": 3,
"duplicate_event": 2,
"amount_delta": 6
}
}, },
{ {
"day": 1, "day": 1,
"date": "2026-06-02", "date": "2026-06-02",
"events": 49722, "events": 49727,
"sha256": "5f7aa0301832008db612eff05899b1210af958c888ac2e9e89f4b7a552569eff" "sha256": "8005d2934eb353478e35331d7ae7ae6876c7934b2d9032fde92cf6ce86d6460f"
}, },
{ {
"day": 2, "day": 2,
"date": "2026-06-03", "date": "2026-06-03",
"events": 52361, "events": 52363,
"sha256": "10ac9ce65ca6dc171689edf78ef5cd785e8c8a80e54d236bf2b9d6dbf6997070" "sha256": "e13f6f3f3977386a94d0e63b6849675cb5d2486a277d559fd415923cfc82e53a"
}, },
{ {
"day": 3, "day": 3,
"date": "2026-06-04", "date": "2026-06-04",
"events": 53157, "events": 53159,
"sha256": "7bf4deadd8f69a6c479fceabfe2d1e8ea8db76919b10a8b9e780006708da31c3" "sha256": "91c713add62222adc3f89c04e6cc087d8c44e2385d14d0464e2c016b6952b9fd"
}, },
{ {
"day": 4, "day": 4,
"date": "2026-06-05", "date": "2026-06-05",
"events": 50078, "events": 50087,
"sha256": "b9e758fb72f937aa5ad1f2e90dee5ec147b16cfd89e49e51a00582f909458c29" "sha256": "b7fc27d73fc157a48bece0189c7983f6bc672f052e0703cd799b9774da4b904e"
}, },
{ {
"day": 5, "day": 5,
"date": "2026-06-06", "date": "2026-06-06",
"events": 47164, "events": 47169,
"sha256": "f08a9f9a439f1da9e4ad8d94b9f65b7f3329256e41446d22d900cc4825f2480c" "sha256": "82cb8017d53d6dee8438d3683b7a901f1ace139f3b8ae836215722bb580fe207"
}, },
{ {
"day": 6, "day": 6,
"date": "2026-06-07", "date": "2026-06-07",
"events": 48683, "events": 48686,
"sha256": "7e45d9dd59c902067fa6847d813669b1ab9a4ee88d2f192382577a5ed8d590a7" "sha256": "b50ad0fcc7d1ea0c637cfcba98e5c2b55d65215cffa3693bc77edeaa83e6c783"
}, },
{ {
"day": 7, "day": 7,
"date": "2026-06-08", "date": "2026-06-08",
"events": 49394, "events": 49394,
"sha256": "286ccc2451847ac8de215b5e902c328371021ad5502bd4afca0494b6f313aef7" "sha256": "358d51b36221d822960237702fa6327fb663a57cf49cc4415a859b16e5aa05a2"
} }
], ],
"snapshots": [ "snapshots": [
{ {
"day": 0, "day": 0,
"date": "2026-06-01", "date": "2026-06-01",
"sha256": "6c3e3c1c8689ec23f53f0953db1b941e6f010e9e56a74d8d017f40195d3dec17" "sha256": "93b39f261cbbe894402ebe0525460fed1809e5712639be4b73b79dac2b39f5bd"
}, },
{ {
"day": 1, "day": 1,
"date": "2026-06-02", "date": "2026-06-02",
"sha256": "9acb523cfd47f3d97d55122025852982ba5a1d8362036308ce72a4d9fb767f43" "sha256": "4fef382cd84868ccfb61a9d8610cdd26f9a749bbae8b20800c45e520e9ce414e"
}, },
{ {
"day": 2, "day": 2,
"date": "2026-06-03", "date": "2026-06-03",
"sha256": "b81482491c4c0b874ea84d5f3f05144100c906dacc059c068b331df258b97172" "sha256": "caf9ed2e77d2fcc99066a548dcba60f61f1ff0d4402de31457a06f23fd2d8306"
}, },
{ {
"day": 3, "day": 3,
"date": "2026-06-04", "date": "2026-06-04",
"sha256": "9d1ab326c4bf8b78c9fcaf7015d8029f9ad4cff5558b89c730eb428d175cc9ee" "sha256": "cdb9e4e00b57c04985866b5a851a8e2f23b3e9205ea131c7a5076672ee8b5669"
}, },
{ {
"day": 4, "day": 4,
"date": "2026-06-05", "date": "2026-06-05",
"sha256": "0e30d0e192e121121003c0cc9aaa55e24924afb983caff15ab105705fbf2bd00" "sha256": "c58d2c20fe3b4cee2973f34c322090dbb66d3793bfaab0337c22b53fb5a3ec7f"
}, },
{ {
"day": 5, "day": 5,
"date": "2026-06-06", "date": "2026-06-06",
"sha256": "9a2ad812fcff9a40a988f24f8aa4fc7a379873d173a95ddacd438592aed0bfb5" "sha256": "bbf28b1d375e4815695c72a5915a22a3c9b36195b97ec4cc7c739466dbe70b53"
}, },
{ {
"day": 6, "day": 6,
"date": "2026-06-07", "date": "2026-06-07",
"sha256": "e1706a27ff55de489a505489148c7a959329d95ad6597d92d3f78505f67bb221" "sha256": "8ca077576bcf014c0d492c7d7190e38c0e46ec95951ad840df93eb75ee5a00d3"
} }
] ]
} }
+39 -11
View File
@@ -10,6 +10,19 @@
обработчик DAG. Triggerer не запускается: в каркасе нет отложенных задач. обработчик DAG. Triggerer не запускается: в каркасе нет отложенных задач.
Два интеграционных пробника написаны через публичный `airflow.sdk`. Два интеграционных пробника написаны через публичный `airflow.sdk`.
Долгоживущие процессы Airflow зависят от `airflow-init`, а Superset — от
`superset-init`. Это ребро нужно самому `up --wait`: Docker Compose 2.40.3
считает успешно завершившийся одноразовый сервис без зависимого упавшим и
возвращает единицу — поведение проверено при исполнении #37. Зависимость
включает результат подготовки в общий граф ожидания.
У каждого общего локального образа один блок `build`. Он находится у службы
инициализации. Остальные службы зависят от неё и используют тот же `image`.
`docker compose up --build` завершает сборку до запуска контейнеров, поэтому
одного блока достаточно. В Buildx 0.30.1 одинаковые блоки у нескольких служб
запускают параллельный экспорт одного тега. Иногда сборка завершается ошибкой
`image already exists`.
Для входа выбран SimpleAuthManager. У него нет команды создания пользователя, Для входа выбран SimpleAuthManager. У него нет команды создания пользователя,
поэтому `airflow-init` записывает пароль администратора из окружения в поэтому `airflow-init` записывает пароль администратора из окружения в
JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит
@@ -24,13 +37,20 @@ JSON-файл отдельного тома `airflow_auth`. Новый имен
пользователи и строки подключения. Это экономит один контейнер, но сохраняет пользователи и строки подключения. Это экономит один контейнер, но сохраняет
границу владения данными. границу владения данными.
Airflow получает подготовленное общее подключение к `clickhouse-01`, а Airflow получает два подготовленных подключения к `clickhouse-01`: рабочее
Superset — подключение `clickhousedb://` к `clickhouse-02`. Провайдер `clickhouse_default` и административное `clickhouse_lifecycle` для дагов
ClickHouse для Airflow не нужен: локальный образ содержит прямой клиент. жизненного цикла мира ([ADR 0013](0013-world-lifecycle.md)). Оба имеют тип
Пробник создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через `clickhouse`, который регистрирует официальный провайдер
`remote` читает `Distributed` на второй. Разные ноды сохраняют учебную `apache-airflow-providers-clickhousedb`. SQL-файлы исполняет общий
ловушку: забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow `SQLExecuteQueryOperator`, а программные запросы идут через `ClickHouseHook`.
на первой ноде прошла успешно. Сам провайдер работает через `clickhouse-connect`; его версия закреплена
отдельно, чтобы сборка не меняла транспорт незаметно.
Superset получает подключение `clickhousedb://` к `clickhouse-02`. Пробник
Airflow создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через
`remote` читает `Distributed` на второй. Разные ноды сохраняют учебную ловушку:
забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow на первой
ноде прошла успешно.
Клиент Kafka также добавлен прямо в образ Airflow. Официальный провайдер Клиент Kafka также добавлен прямо в образ Airflow. Официальный провайдер
использует тот же `confluent-kafka`, а пробнику не нужны его подключение, использует тот же `confluent-kafka`, а пробнику не нужны его подключение,
@@ -76,10 +96,18 @@ Prometheus читает встроенные точки метрик двух с
Оттуда взяты `airflow.sdk`, обязательный отдельный обработчик DAG, возможность Оттуда взяты `airflow.sdk`, обязательный отдельный обработчик DAG, возможность
не запускать triggerer и проверка конкретных компонентов здоровья. не запускать triggerer и проверка конкретных компонентов здоровья.
Для интеграционных пробников проверены `Connection.get` в публичном Для ClickHouse через Context7 и официальную документацию Airflow проверены
`airflow.sdk`, запросы через `clickhouse-connect` и состав официального `ClickHouseHook`, тип подключения `clickhouse` и работа
провайдера Kafka. Выбран прямой `confluent-kafka`: провайдер строит свои `SQLExecuteQueryOperator` с SQL-файлом, параметрами и несколькими выражениями.
подключения и обёртки поверх него, которые двум коротким пробникам не нужны. Закреплён `apache-airflow-providers-clickhousedb==1.0.0`: на 21 августа
2026 года это последний стабильный выпуск; `1.0.1` опубликован только как
предварительная версия. Зависимость провайдера допускает любой
`clickhouse-connect>=1.3.0`, поэтому прежний `clickhouse-connect==1.6.0`
оставлен прямой зависимостью образа.
Для Kafka проверен состав официального провайдера. Выбран прямой
`confluent-kafka`: провайдер строит свои подключения и обёртки поверх него,
которые двум коротким пробникам не нужны.
Драйверы и строки подключения проверены по документации Superset 6.1.0: Драйверы и строки подключения проверены по документации Superset 6.1.0:
[подключения к базам](https://superset.apache.org/user-docs/6.1.0/databases/), [подключения к базам](https://superset.apache.org/user-docs/6.1.0/databases/),
+21 -1
View File
@@ -120,4 +120,24 @@ ClickHouse, Kafka и Airflow, 17 — в дашборде генератора, 1
модельные сутки значит записать ускорение мира в JSON дашборда — ровно ту модельные сутки значит записать ускорение мира в JSON дашборда — ровно ту
пометку, от которой этот же ADR отговаривает выше, — а реальные сутки на стенде пометку, от которой этот же ADR отговаривает выше, — а реальные сутки на стенде
не с чем сравнивать. Довод целиком и остальное устройство дашборда — не с чем сравнивать. Довод целиком и остальное устройство дашборда —
[docs/architecture/monitoring.md](../architecture/monitoring.md). [docs/architecture/monitoring/data.md](../architecture/monitoring/data.md).
Тогда же собраны «Кластер» и «Запросы». Довод про `clusterAllReplicas`
подтвердился с одной поправкой: очередь распределённых DDL лежит не у ноды, а в
keeper, и обе ноды читают её целиком — запрос вернул с каждой одни и те же 124
записи. Обёртка там не добавила бы правды, а удвоила бы каждую строку, поэтому
единственная панель «Кластера» без неё — эта. Keeper своих системных таблиц не
имеет вовсе, и панель по нему приходит из Prometheus.
«Запросы» получили на две панели больше, чем сказано выше. Разбор запросов по
посетителям продиктован [ADR 0007](0007-clickhouse-access.md): своя учётка
Grafana заведена там ровно затем, чтобы её запросы были отличимы от
человеческих.
Отдельная панель ошибок понадобилась потому, что отказ достаётся шарду: в
замере с принудительным пределом памяти каждая нода записала свой, и строка
шарда видна только без фильтра по инициатору. Здешний сюжет про тяжёлый
`GROUP BY` при этом кончается не отказом, а сбросом на диск
([ADR 0004](0004-resource-limits.md)) — отказ на стенде приходится вызывать
настройкой. Устройство обоих дашбордов —
[cluster.md](../architecture/monitoring/cluster.md) и
[queries.md](../architecture/monitoring/queries.md).
+21 -4
View File
@@ -19,16 +19,23 @@
Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел
памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и
о самой машине ничего не знает. Остальные `mem_limit` не меняются. о самой машине ничего не знает. На 1 августа остальные `mem_limit` не менялись.
Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются
статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`, статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`,
`AIRFLOW__CORE__PARALLELISM` и девять неизменённых `mem_limit`. Основания, `AIRFLOW__CORE__PARALLELISM` и восемь оставшихся `mem_limit`. Основания,
кроме отменённого, у них нет. Кто в них упрётся — меняет по первому кроме отменённого, у них нет. Кто в них упрётся — меняет по первому
свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus
сюда не входит: неделя истории — осознанный учебный выбор, и цена у него сюда не входит: неделя истории — осознанный учебный выбор, и цена у него
дисковая. дисковая.
27 августа таким пределом впервые стала коробка `airflow-scheduler`:
LocalExecutor исполняет в ней до четырех процессов задач одновременно, а
640 МиБ этого не вместили. Коробка поднята до 1,5 ГиБ. Ресурсные ограничения
из отдельных дагов удалены. Общий предел параллелизма уже задает Airflow.
`max_active_runs=1` остается там, где два запуска делили бы один донор и могли
повредить публикацию.
Ресурсный довод ADR 0001 отозван. Ресурсный довод ADR 0001 отозван.
## Почему ## Почему
@@ -94,8 +101,8 @@ ClickHouse сам понижает каждый до 512 МиБ, а на сте
заранее, и в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме заранее, и в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме
пределов, а работе с запасом. пределов, а работе с запасом.
Остальные девять лимитов не меняются, потому что ни один из них ни разу не На 1 августа остальные девять лимитов не менялись, потому что ни один из них
сработал. Снять их скопом — то же изменение без свидетельств, каким они были не сработал. Снять их скопом — то же изменение без свидетельств, каким они были
выставлены, только в обратную сторону. Лечение здесь не в том, чтобы стереть выставлены, только в обратную сторону. Лечение здесь не в том, чтобы стереть
числа, а в том, чтобы лишить их статуса закона: число без основания меняют, числа, а в том, чтобы лишить их статуса закона: число без основания меняют,
когда оно мешает, и не защищают как принятое решение. когда оно мешает, и не защищают как принятое решение.
@@ -151,3 +158,13 @@ to 2.00 GiB because the system has limited RAM` и такие же строки
Значение `max_server_memory_usage_to_ram_ratio` = 0,9 и то, что в cgroup предел Значение `max_server_memory_usage_to_ram_ratio` = 0,9 и то, что в cgroup предел
считается от коробки, а не от машины, сверены по документации ClickHouse через считается от коробки, а не от машины, сверены по документации ClickHouse через
MCP Context7. MCP Context7.
`airflow-scheduler` проверен отдельно 27 августа 2026 года. На свежем
контейнере две попытки запустить четыре DQ-сверки параллельно в коробке
640 МиБ дошли ровно до `memory.max` и дали `oom_kill=1`: сначала при выгрузке
57 567 ключей сессий в Python, затем и после переноса дневной агрегации в
ClickHouse. В коробке 1 ГиБ сам `dq_check` прошел, но снова коснулся верхней
границы (`memory.events.max=57`). Полный `etl_pipeline` без локальных
ограничений задач в коробке 1,5 ГиБ завершился успешно: пик - 1 013 760 000
байт, `memory.events.max=0`, `oom_kill=0`. Docker не резервирует разницу между
пиком и пределом, поэтому этот запас не увеличивает расход памяти в покое.
+4
View File
@@ -2,6 +2,10 @@
Дата: 3 августа 2026 года. Статус: принято. Дата: 3 августа 2026 года. Статус: принято.
Позднее тикет #214 сменил имя слоя: вместо «сырья» — сырые данные, сырой слой,
STG. Текст ниже оставлен в словах своего времени, действующее имя — в
`CONTEXT.md`.
## Решение ## Решение
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
+47 -30
View File
@@ -1,18 +1,23 @@
# ADR 0007. Доступ к ClickHouse: пользователи файлом, доверие нод секретом # ADR 0007. Доступ к ClickHouse: пользователи файлом, доверие нод секретом
Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader` Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader`
и пользователь `grafana` добавлены 21 августа 2026 года (#114). и пользователь `grafana` добавлены 21 августа 2026 года (#114), права
`bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150).
## Решение ## Решение
У кластера шесть пользователей и пять ролей. У кластера семь пользователей и шесть ролей.
- `default` — с паролем, только служебный: проверки здоровья нод и работа - `default` — с паролем, только служебный: проверки здоровья нод и работа
изнутри контейнеров. Приложения им не ходят. изнутри контейнеров. Приложения им не ходят.
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит - `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходят рабочие
Airflow и применяется DDL при подъёме стенда. даги Airflow.
- `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`. - `lifecycle` с ролью `lifecycle_owner` — создание и удаление только
Им ходит Superset. прикладных баз и объектов. Им даги жизненного цикла применяют DDL и
пересоздают мир ([ADR 0013](0013-world-lifecycle.md)).
- `bi` с ролью `bi_reader` — чтение слоёв ODS, DDS и DM, а также справочников
`dic`. Им ходит Superset; его запросы обращаются к публичным `_v`, хотя права
шире.
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им - `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
человек подключается снаружи, из своего клиента. человек подключается снаружи, из своего клиента.
- `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и - `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и
@@ -38,24 +43,28 @@
Сетевых ограничений на `default` нет: порты стенда и так открыты только на Сетевых ограничений на `default` нет: порты стенда и так открыты только на
`127.0.0.1`, а внутри сети Compose дверь заперта паролем. `127.0.0.1`, а внутри сети Compose дверь заперта паролем.
Устройство модели — кто каким пользователем ходит и где живут пароли — Раздел README «Адреса и пароли» описывает, кто каким пользователем ходит и где
опишет раздел README «Состав и доступ»; здесь только решение и доводы. живут пароли; здесь остаются решение и доводы. Отдельного справочника по
Отдельного справочника по доступу в `docs/architecture/` не заводим: своего доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция
содержания сверх этих двух документов у него сегодня нет, а дом ему чтения через `_v` описана в доке хранилища.
понадобится, когда появятся слои DDS и DM и гранты размножатся. Абзац README
«У локального учебного кластера нет пароля» этим решением отменяется.
## Почему ## Почему
Стенд учит не администрированию доступа, а чтению отказа в правах: человек Стенд учит не администрированию доступа, а чтению отказа в правах: человек
должен узнать в бою картину, которую видел здесь, и понимать, чего просить. должен узнать в бою картину, которую видел здесь, и понимать, чего просить.
Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из
описания. Поэтому пользователей мало, имена у них говорящие, а границы описания. Поэтому пользователей мало, имена у них говорящие. В бою BI обычно
проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой читает витрины и модельный слой. ODS ему могут открыть для расследования или
DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины сверки. Стенд оставляет этот доступ постоянным, но сохраняет две крупные
со слоем ниже остаётся проверяемым одним и тем же пользователем. Справочники границы: `bi` отделён от STG и от записи. Один пользователь может сверить всю
видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её цепочку DM → DDS → ODS, не получая доступа к сырью и изменению данных.
содержимым владеет файл репозитория, а не слой. Справочники видны всем читателям и никому на запись: зона входит в хранилище
сбоку, и её содержимым владеет файл репозитория, а не слой.
Ограничить `bi` одними `_v` поверх `Distributed` можно только отдельным
маршрутом, учётной записью или копией данных. Эта механика не даёт нового урока.
Публичное представление остаётся договором чтения; границу прав оно не задаёт
([исследование](../research/2026-08-25-clickhouse-distributed-view-security.md)).
**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то **Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то
есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно — есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно —
@@ -98,7 +107,7 @@ DDS под ними, потому что так чаще всего и быва
источник истины — файл в git. Стенд считает `access.xml` и переданные через источник истины — файл в git. Стенд считает `access.xml` и переданные через
`.env` значения настройкой при создании контейнера; отдельного способа `.env` значения настройкой при создании контейнера; отдельного способа
применять их на живом стенде нет, потому что уроки не меняют базовую модель применять их на живом стенде нет, потому что уроки не меняют базовую модель
доступа. Порядок применения изменений — в разделе README «Состав и доступ». доступа. Порядок применения изменений — в разделе README «Адреса и пароли».
Оба варианта с SQL — локальное хранилище доступа с `ON CLUSTER` и Оба варианта с SQL — локальное хранилище доступа с `ON CLUSTER` и
реплицированное в keeper — делают пользователей состоянием тома: `.env` на реплицированное в keeper — делают пользователей состоянием тома: `.env` на
них больше не влияет, и появляется расхождение того же сорта, которое README них больше не влияет, и появляется расхождение того же сорта, которое README
@@ -121,16 +130,15 @@ DDS под ними, потому что так чаще всего и быва
## Следствия ## Следствия
До появления слоёв DDS и DM читать `bi` почти нечего, и это намеренно. Новые таблицы ODS, DDS и DM не требуют отдельного гранта для `bi_reader`.
Дашборды рисуются поверх модели данных, а не поверх типизированных событий, Явный договор `_v` защищает потребителя от устройства хранения и случайных
поэтому доступ Superset к ODS не планировался и правами не выдаётся. новых колонок.
Единственное, что ему доступно сегодня, — справочники: с ними зона `dic`
пришла раньше своих потребителей.
Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда DDL для матвью приёма выполняется от имени `lifecycle`. Это видно в журнале
идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью запросов, а при создании ClickHouse проверяет у этой учётки чтение источника и
сохранят нынешнего владельца, а на собранном заново стенде им станет `etl`. вставку в цель. Так отдельная учётка показывает границу между редким
Работают оба — права есть у обоих, — но два стенда в этом расходятся. управлением структурой и повседневной записью `etl`, не выдавая рабочим дагам
право удалять базы.
Лаба «сломать права» вариантом с файлами не закрывается: создать через SQL Лаба «сломать права» вариантом с файлами не закрывается: создать через SQL
новую роль и нового пользователя ничто не мешает, и новую роль разрешено новую роль и нового пользователя ничто не мешает, и новую роль разрешено
@@ -182,11 +190,20 @@ ClickHouse через MCP Context7 (#114).
- Секрет кластера принимается внутри `remote_servers` в виде - Секрет кластера принимается внутри `remote_servers` в виде
`<secret from_env="CLICKHOUSE_CLUSTER_SECRET"/>`. Запрос от `etl` через обе `<secret from_env="CLICKHOUSE_CLUSTER_SECRET"/>`. Запрос от `etl` через обе
ноды выполнился на каждой под пользователем `etl`. ноды выполнился на каждой под пользователем `etl`.
- `from_env` работает у элемента `password`: `etl`, `bi` и `analyst` вошли с - `from_env` работает у элемента `password`: `etl`, `lifecycle`, `bi` и
паролями из окружения и получили свои роли. `analyst` вошли с паролями из окружения и получили свои роли.
- При запросе от `bi` через Distributed на второй ноде `system.query_log` - При запросе от `bi` через Distributed на второй ноде `system.query_log`
показал `user = bi` и `initial_user = bi`. Значит, на соседнем шарде показал `user = bi` и `initial_user = bi`. Значит, на соседнем шарде
применяются права инициатора, а не общей учётной записи. применяются права инициатора, а не общей учётной записи.
- Права на отсутствующие базы `dds` и `dm` приняты. До создания баз они видны - Права на отсутствующие базы `dds` и `dm` приняты. До создания баз они видны
в `SHOW GRANTS FOR etl_writer`; временная таблица в `dm` подтвердила право в `SHOW GRANTS FOR etl_writer`; временная таблица в `dm` подтвердила право
`bi` на чтение после появления базы. `bi` на чтение после появления базы.
Расширение `bi_reader` проверено на живом стенде 25 августа 2026 года (#150):
- `SHOW GRANTS FOR bi_reader` содержит `SELECT` на ODS, DDS и DM;
- `bi` прочитал одинаковое число строк из `ods.event_v` и `dds.event_v`;
- чтение `stg.hits_raw_rep` завершилось ошибкой `ACCESS_DENIED`;
- представление над `Distributed` с `SQL SECURITY DEFINER` на удалённой ноде
сохранило исходного пользователя `bi`. Причина и отклонённые варианты
разобраны в [исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
+8 -1
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 без дедупликации и готовая схема
@@ -141,7 +145,10 @@
Этап 3 забирает у этапа 5 первый настоящий даг. Раздел 9 мастер-спеки отдавал Этап 3 забирает у этапа 5 первый настоящий даг. Раздел 9 мастер-спеки отдавал
даги этапу 5 целиком; приём заказов без дага не существует, поэтому порядок даги этапу 5 целиком; приём заказов без дага не существует, поэтому порядок
меняется. `etl_pipeline` остаётся за этапом 5. меняется. `etl_pipeline` остаётся за этапом 5. Позднее сдвинулся и он:
резолюция #134 родила его этапом 4 — [дневным конвейером
трансформаций](../architecture/etl.md), — а этапу 5 оставила партиционную
переобработку.
Расхождения с мастер-спекой, внесённые тем же коммитом: раздел 6 (чтец заказов Расхождения с мастер-спекой, внесённые тем же коммитом: раздел 6 (чтец заказов
живёт на одной ноде и без матвью), раздел 7 (развилка закрыта, у `orders` одна живёт на одной ноде и без матвью), раздел 7 (развилка закрыта, у `orders` одна
+13 -1
View File
@@ -2,6 +2,10 @@
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79. Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
Позднее тикет #214 сменил имена: «пульт мира» стал дагами модельного времени,
«работник» — шаговым дагом. Текст ниже оставлен в словах своего времени,
действующие имена — в `CONTEXT.md`.
## Решение ## Решение
Ось модельного времени двигают даги пульта, и они двух родов. Ось модельного времени двигают даги пульта, и они двух родов.
@@ -30,6 +34,11 @@
работник берёт из переменной, а не из логической даты прогона: календарь Airflow работник берёт из переменной, а не из логической даты прогона: календарь Airflow
к оси мира отношения не имеет. к оси мира отношения не имеет.
Позднее [ADR 0013](0013-world-lifecycle.md) отдал Airflow начальную позицию и
состояния обслуживания: переменной нет до первого создания мира, `-1` означает
незавершённую очистку, `0` — чистый мир до генерации, а работники принимают
только позицию `8` и больше.
Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, — Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, —
**отложена, а не отвергнута**. **отложена, а не отвергнута**.
@@ -130,7 +139,10 @@
Этапы 3 и 5 зовут генератор тем же приёмом: пакетный забор слепка заказов Этапы 3 и 5 зовут генератор тем же приёмом: пакетный забор слепка заказов
([ADR 0008](0008-order-ingestion.md)) прирастает к тому же работнику, а ([ADR 0008](0008-order-ingestion.md)) прирастает к тому же работнику, а
`etl_pipeline` этапа 5 получает готовую обвязку `DockerOperator` и готовое `etl_pipeline` этапа 5 получает готовую обвязку `DockerOperator` и готовое
правило позиции. правило позиции. Позднее резолюция #134 родила `etl_pipeline` этапом 4 —
[дневным конвейером трансформаций](../architecture/etl.md) без генераторных
шагов: обвязка и правило позиции остаются работникам, а живой день дёргает
конвейер после установки позиции.
Расхождения, внесённые тем же коммитом: раздел 8 спеки генератора (позицию Расхождения, внесённые тем же коммитом: раздел 8 спеки генератора (позицию
двигал «только даг `next_day`»; теперь — всякий, кто сыграл день, значением двигал «только даг `next_day`»; теперь — всякий, кто сыграл день, значением
+3 -1
View File
@@ -1,7 +1,9 @@
# ADR 0010. Заказы в ODS: версии сущности вместо подмены слепка # ADR 0010. Заказы в ODS: версии сущности вместо подмены слепка
Дата: 16 августа 2026 года. Статус: принято. Частично заменяет Дата: 16 августа 2026 года. Статус: принято. Частично заменяет
[ADR 0008](0008-order-ingestion.md). [ADR 0008](0008-order-ingestion.md). Колонка версии пересмотрена
[ADR 0014](0014-order-version-snapshot-date.md): вместо `updated_at` —
`snapshot_date`.
## Решение ## Решение
+70
View File
@@ -0,0 +1,70 @@
# ADR 0013. Жизненный цикл мира ведёт Airflow
Дата: 21 августа 2026 года. Статус: принято. Реализация — тикет #119.
## Решение
Compose поднимает службы стенда. Airflow владеет прикладным миром: топиками
Kafka, DDL прикладных баз ClickHouse, стартовыми данными и позицией модельного
времени. `make` поднимает инфраструктуру и через REST API запускает нужный даг,
дожидаясь его результата.
Безопасный даг `world_initialize` создаёт мир, если тот ещё не создан. На
готовом мире он ничего не меняет и успешно сообщает об этом в журнале.
Разрушительный даг `world_recreate` очищает прикладные базы, топики и позиции
их потребителей, затем запускает `world_initialize` и ждёт его завершения.
Канонический DDL применяет отдельная учётная запись Airflow с правами только на
прикладную структуру стенда.
`world_position` остаётся единственным состоянием пульта:
- переменной нет — стенд ещё не инициализировали;
- `-1` — прежний мир признан недействительным, очистка не завершена;
- `0` — мир очищен и готов к начальной генерации;
- `8` и больше — первый несыгранный день готового мира.
`world_recreate` ставит `-1` до первого удаления и `0` только после успешной
очистки. `world_initialize` принимает отсутствие позиции или `0`, а значение
`8` и больше считает готовым миром. После генерации стартовых дней и успешного
приема обоих источников позиция становится `8`; затем инициализация последним
шагом ждет дневной конвейер `etl_pipeline`. При отказе преобразований мир
остается готовым на позиции `8`, а конвейер после исправления запускают рукой.
Обычные даги движения мира работают только с готовой позицией.
## Почему
Прежняя цепочка делила один процесс между Compose, shell-скриптами и Airflow:
одноразовые службы создавали стартовые данные, отдельный сценарий ждал их
приёма, а пересборка сама исправляла внутреннюю переменную пульта. Из-за этой
границы успешная отправка в Kafka выдавалась за создание мира, повторный
`make up` смешивался с инициализацией данных, а узкая пересборка сохраняла в
Kafka сообщения и офсеты прежнего мира.
Airflow уже ведёт последующие дни и приём заказов. Отдав ему начало той же оси,
получаем один наблюдаемый процесс и одну точку успеха: зеленая ветка создания в
`world_initialize` подтверждает, что схема создана, стартовый мир принят, его
следующий день известен, а слои DDS и DM собраны. Короткая ветка готового мира
по-прежнему подтверждает позицию и ничего не меняет.
## Что это решение уточняет
- В [ADR 0007](0007-clickhouse-access.md) DDL больше не принадлежит `etl`:
прикладную структуру ведёт отдельный `lifecycle`.
- В [ADR 0009](0009-world-control.md) отсутствие `world_position` больше не
означает готовый стартовый мир. Начальную позицию ставит
`world_initialize`, а работники принимают только `8` и больше.
- Одноразовые службы Compose и shell-ожидание, описанные прежним устройством
хранилища, отменены. Их последовательность стала наблюдаемым графом Airflow.
## Отклонено
- Оставить создание мира в Compose, а позицию исправлять из
`make rebuild-storage`: работает технически, но сохраняет двух
оркестраторов одного процесса.
- Один безусловно разрушительный даг для первого и повторного запуска: обычный
`make up` мог бы стереть уже прожитые дни.
- Отдельная переменная состояния мира: она дублировала бы
`world_position` и могла разойтись с ней. Значения `-1` и `0` выражают
нужные переходы в одном месте.
- Выдать обычной учётной записи `etl` право удаления баз: рабочий конвейер не
должен получать административные права из-за редкой операции восстановления.
@@ -0,0 +1,96 @@
# ADR 0014. Версия заказа в ODS: дата слепка
Дата: 26 августа 2026 года. Статус: принято. Пересматривает колонку версии
из [ADR 0010](0010-order-versions-in-ods.md); остальные решения того ADR в
силе. Развилка разобрана в #163, реализация — #165.
## Решение
`ods.order` хранит версии заказа в `ReplacingMergeTree(snapshot_date)`: при
схлопывании выживает строка позднейшего слепка, который нёс заказ. Формула
решения: у полного слепка версия — сам слепок; истина — то, что источник
сказал последним.
Ключ сущности, партиция и шардирование не меняются. `updated_at` остаётся
деловым полем, и деловой ответ слоя от смены версии не зависит: на
канонических слепках выбор по дате слепка побайтово совпадает с выбором по
деловой метке, потому что метка монотонна по слепкам, а слепок дня — чистая
функция зерна и дня (тесты генератора
`test_the_state_is_read_at_the_boundary_of_the_day` и
`test_two_runs_give_the_same_snapshot`).
Решение покупает две вещи.
- **Детерминизм слоя.** Разные слепки несут разные даты — ничьей между
содержательно разными строками не бывает. При версии `updated_at` доставки
неизменившегося заказа равны по версии, и выжившая строка — жребий: у 86
заказов стартового мира выживала строка серединного слепка, притом что у
всех 3034 пар строк с равным `updated_at` деловые поля совпадали побайтово
(замеры #137). Документация ClickHouse обещает при ничьей победу последней
вставленной строки — замер это опроверг, полагаться на неё было бы ошибкой.
- **Правдивый ярлык.** `snapshot_date` выжившей строки — последний слепок,
который нёс заказ. Объём пересборки DDS читается прямо из данных, причём
без `FINAL`: слияние сохраняет максимум даты по построению. Константа
окна K уходит со стороны стенда вместе с риском дрейфа её двух копий
(ревью #151 ставило этому риску MAJOR).
## Ответ доводу ADR 0010
ADR 0010 отверг семейство «публикацию решает дата наблюдения»: у потребителя
нет признака полноты слепка. Довод целил в подмену партиции — ей полнота
нужна, потому что неполная партиция стёрла бы уже принятые версии. Колонке
версии полнота не нужна: она сравнивает свежесть строк одного заказа, и
недоехавшая строка просто оставляет в силе предыдущую — ровно как при версии
по деловой метке.
## Границы допущения
- **Дисциплина слепков.** Слепок с поздней датой не несёт состояния старше.
Источник, собирающий слепок из отставшей реплики, это ломает; модельный
мир — нет: см. тесты выше.
- **Одна дата — один слепок.** Ограничение вшито в движок: вторая
внутридневная выгрузка дала бы ничью уже на деловых полях. Это условие
контракта источника, его место — в границе строгого приёма
([приём заказов](../architecture/orders/ingestion.md)).
- **Окно наследует потерю строк.** Частичный отказ вставки после коммита
офсетов может унести старший день последнего слепка — выведенное окно
молча сузится. Лекарство прежнее, сверка слоя с источником в контуре DQ
([конвейер](../architecture/etl.md), «Чего конвейер о себе не знает»).
- **Тихая правка лечится только внутри окна.** Строку, изменённую без
сдвига `updated_at`, перекроет следующий слепок; замёрзший заказ источник
больше не показывает, там лечит лишь сверка полей.
- **Повторная доставка слепка.** Байты совпадают, жребий остаётся только на
`_load_id` и `_load_ts`; ярлык даты детерминирован.
- **Правка живого мира не подхватится.** DDL исполняется при рождении мира,
`ALTER` колонку версии не меняет: решение вступает в силу пересозданием
хранилища (`make rebuild-storage`).
## Отклонённые варианты
- **Оставить как есть, сверку копий K отдать DQ.** Лотерея ярлыков остаётся
задокументированным грехом, окно — константой с риском дрейфа.
- **Убрать координаты доставки из ODS.** Наказывает поле за вопрос, на
который оно не бралось отвечать: расследованию (связка годных строк,
ошибок и сырья) координаты служили честно.
- **Дедупликация на вставке.** Детерминизм «обычно», не по построению:
анти-джойн с собственной целью не атомарен, лаг реплик протаскивает дубли.
Приём теряет урок «разобрал и вставил», а ярлык со смыслом «первый
носитель» хуже для обеих ролей поля: окну нужен последний носитель, и
сырьё живо скорее у него.
- **Склейка `(updated_at, snapshot_date)` в одну колонку версии.** Движок
берёт одну колонку, склейка требует битовой упаковки. Робастнее к
недисциплинированному источнику, но платит читаемостью DDL за защиту от
сценариев, которых модельный мир не производит.
- **Склейка `(snapshot_date, updated_at)`.** Путь отступления: если у
источника появятся переиздания слепка одной даты, версия дорастает до
этой формы. От правок без сдвига метки внутри одной даты не спасает и она.
## Связанные решения
- [ADR 0010](0010-order-versions-in-ods.md) — версии заказа в ODS; здесь
пересмотрена только колонка версии.
- [Приём заказов](../architecture/orders/ingestion.md) — граница строгого
приёма и четыре координаты строки.
- [Модель заказа в DDS](../architecture/dds/order.md) — читатель окна
пересборки.
- Развилка, веер и два холодных ревью — #163.
+36
View File
@@ -0,0 +1,36 @@
# ADR 0015. Модельный день считается в часовом поясе счетчика
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
## Решение
Модельные сутки считаются в часовом поясе счетчика UTC+4. `EventDate` обозначает
дату в этом поясе, а `UTCEventTime` — абсолютную метку времени. Граница
модельного дня приходится на полночь счетчика.
Суточная волна посетителя привязана к его местному времени. У гостя из другого
пояса она сдвигается на разницу с поясом счетчика, но общая граница дня остается
прежней.
## Почему
Так устроена выгрузка Метрики: дата события уже посчитана в поясе счетчика.
Полночь в этом поясе не разрезает утренние сессии в регионе мира. Регион можно
выбирать независимо от технического пояса счетчика.
Для всего потока дня нужна одна граница суток. Местное время гостя описывает
поведение в пределах этих суток, а отдельная дата для каждого посетителя не
нужна.
## Отклонено
В разделе 9 отклоненные варианты не зафиксированы.
## Следствия
У ночных событий `toDate(UTCEventTime) != EventDate`. Хранилище берет день из
готового `EventDate`, а не выводит его из абсолютной метки. Поворот суточной
волны слегка размывает общий пик активности гостей из разных поясов.
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
раздел 9.
+28
View File
@@ -0,0 +1,28 @@
# ADR 0016. Паспорт куки хранится в плане состава на всю ее жизнь
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
## Решение
Браузер, операционная система, устройство, экран и город принадлежат куке на всю
ее жизнь. План состава выбирает и хранит паспорт, а день только переносит его в
поля событий.
Две куки одного человека получают один город и разные устройства. Броски
паспорта идут после прежних бросков состава, поэтому прежние измерения не
сдвинулись.
## Почему
Кука представляет браузер на устройстве, поэтому ее паспорт не должен меняться
между днями. Только план состава связывает две куки с одним человеком. Поэтому
он может задать им один город и разные устройства.
## Отклонено
- Выводить паспорт арифметикой из `ClientID`: этот идентификатор не несет
сведений о двухкуковой паре, поэтому по нему нельзя задать двум кукам одного
человека один город и разные устройства.
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
раздел 9.
+34
View File
@@ -0,0 +1,34 @@
# ADR 0017. Номер заказа объединяет две проекции покупки
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #40.
## Решение
Номер заказа включает день по модельному времени и порядковый номер покупки в
этот день, например `20260603-0042`. Событийная сторона пишет его в
`purchaseID`, бэкенд — в `order_id`; обе стороны берут один номер из плана
покупки.
Все покупки, попавшие в поток дня, нумеруются в порядке событий и до любых
потерь следующих этапов.
## Почему
По читаемому номеру сразу виден день заказа. Один номер соединяет две проекции
одной покупки. Генераторы кликстрима и бэкенда не согласуют его отдельно.
Присвоение до потерь сохраняет номера остальных заказов: исчезновение одного
события не должно перенумеровывать чужие покупки и разрывать связь с бэкендом.
## Отклонено
- Присваивать номера после внесения потерь: одна потеря сдвинула бы все
следующие номера за день и разорвала бы связь уже созданных проекций.
## Следствия
`purchaseID` и `order_id` изначально совпадают. Из-за расхождения событие может
исчезнуть после присвоения номера, но номера сохранившихся заказов не меняются.
Решение принято при исполнении тикета #40 и зафиксировано в спеке этапа 2,
раздел 9.
+59
View File
@@ -0,0 +1,59 @@
# Слой DDS: бизнес-модель хранилища
Устройство детального слоя: сущности, которыми хранилище говорит с
витринами, их связи и правила слоя. Набор собирается развилками карты #127
(этап 4) и правится по мере постройки.
Рамки заданы мастер-спекой [«Боевой реализм стенда
(v2)»](../../specs/2026-07-30-stand-v2-realism.md): карта таблиц и витрины —
раздел 7, сверка — раздел 4, склейка идентичностей — раздел 5. Дом выбран
развилкой #130 — первой, которой понадобилось записать резолюцию в доки:
записи этапа 4 живут здесь, по хранилищную сторону границы
«источник | хранилище». Устройство источников остаётся их
собственным наборам ([заказы бэкенда](../orders/README.md)) — генератор
переносим и о нашем DDS не знает.
## Карта модели
Целостность модели — связи и ко-локация — живёт в этом разделе; файлы
сущностей держат глубину и на карту ссылаются.
- **Заказ** (`dds.order_v`, хранение `dds.order_rep` / `_dist`) — физическая
сущность: одна строка — заказ в текущем состоянии, позиции массивами в
строке, наполняется заменой партиций — [модель заказа](order.md).
- **Сессия** (`dds.session_v`, хранение `dds.session_rep` / `_dist`) —
расчётная сущность: одна строка — визит,
нарезку по правилу визита стенд делает сам, `VisitID` генератора — эталон
сверки; наполнение заменой дневной партиции — [модель сессии](session.md).
- **Событие** (`dds.event_v`) — контракт над ODS, а не копия: представление,
переводящее событие на язык бизнес-модели, — [контракт события](event.md).
- **Карта идентичностей** (`dds.identity_map_v`, хранение
`dds.identity_map_rep` / `_dist`) — кука ↔ пользователь магазина —
[модель карты](identity.md).
Связи держатся двумя ключами моста (спека, разделы 4 и 5): `purchaseID[1]`
события равен `order_id` заказа — по нему идут сверка и сборка склейки;
`user_id` заказа связывает куки одного человека. Ко-локацию задаёт
шардирование: события и карта идентичностей раскладываются одним выражением
от куки, заказы — от `order_id`. Поэтому обогащение витрин склейкой
локально, а сверка и сборка карты ходят через GLOBAL JOIN с обеими
сторонами-подзапросами ([исследование
соединений](../../research/2026-08-22-distributed-join.md)).
## Правила слоя
- DDS обслуживает человека с дашбордом: время лежит в поясе счётчика,
пересчёт — один раз при наполнении слоя ([дока хранилища](../storage.md),
«Часовые пояса»).
- Публичный договор каждой сущности — `_v`; роли объектов и конвенция чтения
заданы [докой хранилища](../storage.md), «Имена объектов».
- Служебные колонки — координаты собственной загрузки слоя; чужие ярлыки не
переносятся, а спрашиваются у их слоя по ключу сущности. Вывод правила и
цепочка расследования — [модель заказа](order.md).
- Наполняет слой [дневной конвейер](../etl.md): запуск, объём прогона и
замена партиций на кластере — его устройство.
## Родословная
Дом набора и первая сущность собраны развилкой #130 карты #127; форма
набора — [ADR 0011](../../adr/0011-component-docs.md).
+140
View File
@@ -0,0 +1,140 @@
# Контракт события в DDS
Учебный результат: менти видит первый на стенде пример правила «слой — это
контракт, а не обязательно копия данных». Заказ и сессия — физические
таблицы, а событие в DDS не хранится вовсе: `dds.event_v` — представление,
которое переводит событие на язык бизнес-модели, не копируя ни строки.
Витрины DM читают его, а не ODS напрямую.
## Форма
Обычное представление над `ods.event_v` — вторым уровнем, так что `FINAL`
остаётся спрятанным в один слой, внутри контракта ODS. Единственное
сомнение против такой формы — «представление материализуется до фильтра» —
снято исследованием [проталкивания
условий](../../research/2026-08-22-view-predicate-pushdown.md): условия
внешних запросов доезжают до партиций и первичного ключа сквозь два уровня
представлений и переименование колонок.
Две оговорки исследования соблюдены контрактом. Ключевая для срезов колонка
`event_date` отдаётся переименованием `EventDate`, не пересчётом — иначе
индекс перестал бы её анализировать. Цена `FINAL` — отключённый
автоматический `PREWHERE` — свойство ODS, представление её не усугубляет.
Как публичный договор DDS, представление перечисляет колонки явно и следует
общей [конвенции чтения](../storage.md), «Имена объектов».
## Язык модели
Имена атрибутов — имена модели, а не нормализованные имена источника
([CONTEXT.md](../../../CONTEXT.md), «Нормализованное имя»). Для большинства
колонок они совпадают: атрибут переезжает под нормализованным именем из
[описания выгрузки](../../formats/clickstream-event.md). Расхождения два,
и каждое — решение модели:
- **`WatchID` → `event_id`**: модель зовёт сущность событием, «watch» —
жаргон выгрузки Метрики.
- **`UTCEventTime` → `event_time` в поясе счётчика** (`Europe/Samara`):
DDS обслуживает человека с дашбордом, время лежит местным ([дока
хранилища](../storage.md), «Часовые пояса»). Пересчёт — смена линзы, байты
те же, проталкиванию не мешает. Второй метки времени контракт не отдаёт:
выбор линзы слой сделал за читателя, UTC остаётся языком ODS.
`ClientID` остаётся `client_id`: это устоявшееся имя куки во всей
экосистеме Метрики, по нему шардирование и мост к `dds.identity_map_v`;
своё имя здесь создало бы третий синоним. `client_timezone` тоже остаётся —
это атрибут посетителя, а не линза времени.
`purchase_revenue` сохраняет смысл поля источника. Тип денег нормализуется в
ODS; точный тип и проверка описаны в разделе
[«Актуальные события»](../storage.md#актуальные-события). DDS только
переименовывает уже нормализованную колонку.
## Расшифровки
Одна: `device_category` — коды 1–4 в строки `desktop` / `mobile` /
`tablet` / `tv`. У настоящей Метрики этот атрибут и есть строка (выгрузка
стенда несёт число — принятое отступление: спека, раздел 1.1), контракт возвращает
ему боевой вид.
Значения `device_category` 1–4 получают имена, остальные сохраняются
десятичной строкой. Новый код остаётся видимым, пока у него не появится имя в
договоре.
Остальные кодированные атрибуты не расшифровываются. Для `goals_reached`
справочника целей на стенде нет, id и есть язык. Регионы в расшифровке не
нуждаются: выгрузка несёт названия рядом с id (`region_city`,
`region_country`), словарь ничего бы не раскрыл. Идея дать менти собрать
справочник регионов самому ушла в лабы — issue #143.
## Что скрыто
- **`VisitID`** — чужой суррогат сессии: свою сессию модель чеканит сама
(`session_id`, [модель сессии](session.md)), а эталон — инструмент
проверки, не атрибут модели. DQ-сверка нарезки идёт за эталоном в
`ods.event_v`: она по построению сопоставляет наш слой с чужим чеканом —
честно, что за чужим она и идёт в его слой.
- **`Sign`** — колонка формата без механики: генератор исправлений не шлёт,
и в модели события колонке ответить нечего. Честный рассказ о ней живёт в
DDL ODS и лекции.
- **`ecommerce`** — сырой JSON из STG. Это содержимое источника; отдельным
атрибутом модели оно не становится. Разобранные массивы `purchase_*` и
`product_*` в контракте есть; лаба про разбор JSON внутри колонки читает ODS.
- **`_load_ts`** — у представления нет собственной загрузки, а чужие
координаты слой не переносит (правило слоя — [модель
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
ключу события.
Остальные колонки отдаются целиком, включая `ip_address` и оба
`region_*_id`: контракт — не цензор, он прячет только то, чему в модели
нечего сказать.
## Читатели
- Сборка сессий: ключи нарезки (`client_id`, `event_time`,
`event_date`), тип события, страница, UTM, паспорт куки — категория
устройства приходит уже расшифрованной.
- Витрины `dm.events_enriched_v`, `dm.top_pages_daily_v` и
`dm.utm_effectiveness_v` (`declared_revenue` берется из `purchase_revenue`).
- Событийная сторона сверки `dm.purchase_vs_orders_v`: `purchase_id`,
`purchase_revenue`, `event_date`.
- DQ-сверка сессий с эталоном читает не его, а `ods.event_v` —
см. «Что скрыто».
## Не входит
- Форма витрин DM — #135.
- Лаба со справочником регионов — issue #143, проектирование лаб.
## Отклонённые варианты
- **Материализация вместо представления.** Дублировала бы широкий ODS ради
переименований, и урок «слой — контракт, а не копия» пропал бы;
единственный довод за — сомнение в проталкивании условий — снят
исследованием #128.
- **`utc_event_time` второй меткой рядом с `event_time`.** Контракт вернул
бы читателю выбор линзы, который слой обязан делать сам; расхождение двух
часов — урок ODS, не DDS.
- **`visit_id` в контракте.** Сверка и лаба читали бы одну поверхность, но
чужой чекан встал бы атрибутом модели — тем же доводом модель сессии не
пустила его в свою строку. Решение владельца: DQ-опыт показательнее,
когда эталон приходит из другого слоя.
- **`cookie_id` вместо `client_id`.** Честное имя модели, но третий синоним
куки на ровном месте: проза говорит «кука», экосистема — `ClientID`.
- **Словарь регионов на стенде.** Повторил бы урок `dictGet` каталога
вторым разом, а раскрывать нечего — названия уже в строке. Идея «сделать
руками» ушла в лабы (issue #143).
- **Прочие расшифровки** (`goals_reached` в имена целей, `has_gclid` в
Bool). Справочника целей нет; косметика без читателя.
## Связанные решения
- [Проталкивание условий сквозь
представления](../../research/2026-08-22-view-predicate-pushdown.md) —
резолюция #128: форма контракта и обе её оговорки.
- [Модель сессии](session.md) — `session_id` и довод против чужого чекана;
сюда же передан вопрос о судьбе `VisitID`.
- [Модель заказа](order.md) — правило служебных колонок слоя.
- Мастер-спека, раздел 7 — два контракта: `ods.event_v` хранит язык
источника, `dds.event_v` говорит языком модели.
- [CONTEXT.md](../../../CONTEXT.md) — «Нормализованное имя», «Хранилище».
+122
View File
@@ -0,0 +1,122 @@
# Карта идентичностей в DDS
Учебный результат: менти строит физический мост между кукой трекера и
пользователем магазина, раскладывает его по ключу будущего соединения и видит,
как пакетный полный пересчёт сочетается с точным публичным представлением.
## Назначение
Карта — адресная книга: «эта кука уже покупала как этот пользователь
магазина». Связь становится известна только после покупки, потому что в
кликстриме есть `client_id`, а `user_id` живёт в заказе. Две куки одного
покупателя дают две строки с одним `user_id`; поэтому витрина может показать
рядом число посетителей по кукам и число известных людей после склейки.
Это не полная клиентская база. Куки без покупки остаются анонимными, а карта не
пытается угадать их владельца.
## Зерно и состав строки
Одна строка — известная пара `client_id` × `user_id`. Деловых колонок две:
- `client_id UInt64` — кука трекера;
- `user_id UInt64` — пользователь магазина.
Собственные служебные колонки `_load_id String` и
`_load_ts DateTime64(3, 'UTC')` показывают, каким запуском и когда пара записана
в DDS. Они имеют читателя при расследовании повтора и потому входят в публичный
договор вместе с деловой парой.
`first_matched_at` нет. Запланированные витрины его не читают, а два возможных
смысла — «первая покупка вообще» и «момент, с которого связь была известна
хранилищу» — требуют разных исторических моделей. Этот вопрос оставлен
[самостоятельной работе](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/145),
а текущий стенд не выдаёт один смысл за другой.
Статус заказа связь не меняет: любой заказ, сопоставленный с событием покупки,
показывает владельца куки. Отмена меняет состояние заказа, но не личность
покупателя.
## Физическая модель и публичный интерфейс
- `dds.identity_map_rep` — локальная
`ReplicatedReplacingMergeTree(_load_ts)`, `ORDER BY (client_id, user_id)`,
без партиционирования;
- `dds.identity_map_dist` — распределённая поверхность записи, шардирование
по `cityHash64(client_id)`;
- `dds.identity_map_v` — публичное обычное представление над `_dist`, которое
перечисляет четыре колонки явно и применяет `FINAL`.
Повторы полного запуска физически могут лежать рядом до фонового слияния.
`FINAL` оставляет одну последнюю версию пары, поэтому пользователь всегда читает
точную карту через `_v`. Роли объектов и доступ следуют общей
[конвенции](../storage.md), «Имена объектов».
Шардирование совпадает с событиями и сессиями. Благодаря этому витрина может
обогащать их картой локально по `client_id`; точную форму распределённого
запроса решает проектирование витрин #135.
## Сборка
Карта собирается один раз в суточной пакетной цепочке после обновления модели
заказов и до зависимых витрин. При догоняющем запуске за несколько дней хватает
одной сборки после всей пачки заказов.
Источники — вся доступная история `dds.event_v` и текущее состояние заказов
`dds.order_v`. Трансформация:
1. оставляет события `purchase` и разворачивает единственный `purchase_id`;
2. соединяет его с `order_id` заказа через `GLOBAL INNER JOIN`;
3. выбирает различные пары `client_id`, `user_id`;
4. добавляет координаты текущего запуска и пишет полный набор в
`dds.identity_map_dist`.
`GLOBAL` нужен потому, что события разложены по `client_id`, а заказы — по
`order_id`: строки соединения не ко-локированы. Правая сторона мала, поэтому
рассылка заказов приемлема. Материализованное представление здесь не подходит:
заказ может появиться в пакетном слепке позже уже принятого события покупки.
## Повтор запуска
Каждый запуск заново получает все известные пары из источников. Он не читает
предыдущую карту и не делает anti-join «вставить только отсутствующее».
`_load_ts` служит версией `ReplacingMergeTree`, а `dds.identity_map_v` прячет
физические повторы через `FINAL`.
Полный пересчёт выбран потому, что карта стенда мала, источники уже содержат
нужную историю, а повтор задачи не зависит от того, на каком месте оборвался
предыдущий. Мир стенда не отзывает связь куки с пользователем, поэтому удалять
исчезнувшие пары не требуется.
## Читатели
- `dm.daily_traffic_v` — посетители по `client_id` и известные пользователи по
`user_id` рядом;
- учебные запросы — показать, как несколько кук одного человека меняют
`uniq`;
- расследование пакетного запуска — `_load_id` и `_load_ts` текущей версии
пары.
## Отклонённые варианты
- **`first_matched_at` в текущей модели.** Нет запланированного читателя и нет
однозначной временной семантики; идея передана в issue #145.
- **Словарь ClickHouse.** Для малой карты мог бы ускорить точечное обогащение,
но спрятал бы физическую модель и не добавил нового урока рядом с уже
существующим словарём товаров.
- **Одно динамическое представление без физической карты.** Повторяло бы
соединение всей истории при каждом чтении, не давало ко-локации результата и
не показывало устройство реального слоя хранения.
- **Инкремент через anti-join с самой картой.** Делал бы результат предыдущего
запуска обязательным входом и усложнял повтор без пользы на объёме стенда.
- **Материализованное представление от событий.** Не увидело бы заказ, который
приехал позже события покупки.
## Связанные решения
- [Карта DDS](README.md) — связи сущностей и публичная поверхность `_v`.
- [Модель заказа](order.md) — текущее состояние заказа и пакетное наполнение.
- [Контракт события](event.md) — поля событий на языке DDS.
- [Исследование распределённых соединений](../../research/2026-08-22-distributed-join.md)
— форма `GLOBAL JOIN` и капкан `INSERT SELECT` между разными ключами
шардирования.
+134
View File
@@ -0,0 +1,134 @@
# Модель заказа в DDS
Учебный результат: менти проектирует бизнес-сущность поверх готового ODS —
выбирает зерно, состав строки и способ наполнения — и видит идемпотентность
трансформации, устроенную иначе, чем в приёме: заменой партиции, а не
версиями.
## Зерно
Одна строка — заказ в текущем состоянии, ключ — `order_id`. Модель отвечает
на вопрос «что сейчас известно о заказе»; историю версий не хранит:
читателя у истории нет ни в одной витрине, а ODS её и не обещает —
промежуточные версии съедают фоновые слияния. «Дыхание» выручки внутри окна
K живёт на текущем состоянии само: пересборка дня меняет статусы заказов, и
число дня меняется вместе с ними.
## Состав строки
Деловые поля источника переезжают из `ods.order_v` без переименований —
бэкенд уже говорит в snake_case: `order_id`, `user_id`, `status`, деньги в
`Decimal` (`items_total`, `discount`, `delivery`, `total`).
Времена `created_at` и `updated_at` пересчитываются при загрузке в пояс
счётчика: DDS обслуживает человека с дашбордом ([дока
хранилища](../storage.md), «Часовые пояса»). Там же один раз считается
**день заказа** — колонка `order_date`, по образцу `EventDate` у события:
суточные срезы группируются по готовой дате и пояса не упоминают.
Позиции разобраны из сырой строки `items` в три типизированных массива
одной длины — артикул, количество, цена (`Decimal`). Это родная для
ClickHouse денормализация: позиция не живёт без заказа и собственного ключа
не имеет, а разворачивает массивы читатель — `ARRAY JOIN` в витрине
выручки, с обогащением категорией через `dictGet`
([мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 7). Разбор
JSON происходит один раз, на границе ODS → DDS, куда его и положила спека
(раздел 2); записывать его параллельными колонками-массивами или `Nested` —
выбор DDL, не модели.
## Движок и наполнение
`dds.order_rep` — `ReplicatedMergeTree` без колонки версии: партиция —
`order_date` (день заказа неизменен), ключ сортировки — `order_id`.
`dds.order_dist` шардирует по `cityHash64(order_id)`, тем же выражением, что
ODS: это единственный устойчивый ключ сущности.
Идемпотентность держит замена партиции, а не версии. Сбор дня — батч после
прихода слепка: трансформация собирает партицию заново из `ods.order_v` и
атомарно подменяет. Поэтому каждая строка физической таблицы — ровно один
заказ, а чтению не нужен `FINAL`. Пока окно K живо, день «дышит» — статусы
заказов меняются, и партиция дня пересобирается очередным запуском; на выходе
из окна день замерзает.
Публичная поверхность — `dds.order_v`: обычное представление с явным списком
деловых и собственных служебных колонок над `dds.order_dist`, без `FINAL`.
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
объектов».
Механика запуска — где собирается партиция и как подменяется на кластере —
сюда не входит: это устройство [дневного конвейера](../etl.md).
## Служебные колонки
Строка получает `_load_id` и `_load_ts` запуска трансформации, собравшего
её партицию, — по общей конвенции хранилища. При замене партиции они
одинаковы у всех строк дня и отвечают на вопрос «каким запуском и когда
собран этот день».
Ярлыки ODS (`snapshot_date`, `_load_id` приёма, `_load_ts` прибытия) в модель
не переезжают. Они описывают наблюдение и доставку строки в ODS. Модель DDS
хранит текущее деловое состояние заказа. В ODS `snapshot_date` задаёт версию и
называет последний слепок, в котором источник показал заказ
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Дату
наблюдения и координаты доставки читатель спрашивает у ODS по
`order_id`. Правило слоя:
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
спрашиваются у их слоя по ключу сущности**.
Происшествие качества данных расследуется цепочкой, где каждый шаг спрашивает
свой слой на его языке: по `_load_id` строки DDS видно, какой запуск собрал
день; по `order_id` ODS показывает физические версии и брак — там свой
`_load_id` приёма связывает годные строки, ошибки и данные STG; дальше читают
STG, пока данные живы. Окно расследования задано сроками хранения: сырые данные
— трое суток, брак — месяц ([дока хранилища](../storage.md)).
## Читатели
- Витрина выручки `dm.revenue_daily_v`: заказы в `paid`, `ARRAY JOIN`
позиций, категория через `dictGet` (спека, раздел 7).
- Сверка `dm.purchase_vs_orders_v`: `order_id`, `order_date`, `items_total`,
`status`; соединение — GLOBAL JOIN с обеими сторонами-подзапросами
([исследование соединений](../../research/2026-08-22-distributed-join.md)).
- Сборка карты идентичностей: пара `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) — версии заказов в
ODS, устойчивая поверхность `ods.order_v`.
- [Приём заказов](../orders/ingestion.md) — граница строгого приёма и
четыре координаты строки ODS.
- [Проталкивание условий сквозь
представления](../../research/2026-08-22-view-predicate-pushdown.md) —
резолюция #128 той же карты: цена `FINAL` и цепочек представлений, фон, на
котором здесь выбрана физическая таблица.
+169
View File
@@ -0,0 +1,169 @@
# Модель сессии в DDS
Сессия объединяет события одного визита. На стенде вы учитесь определять
ее границы по событиям, вычислять устойчивый идентификатор и сверять
результат с эталоном. Поэтому сборка читает `dds.event_v`, где нет готового
`VisitID`. Этот идентификатор источника остается в ODS для проверки.
## Зерно
Зерно описывает, чему соответствует одна строка: здесь это один
[визит](../../../CONTEXT.md#люди-и-события). В него входят подряд идущие
события одного `client_id` без пауз длиннее 30 минут и без перехода через
модельную полночь. Пауза ровно в 30 минут новую сессию не открывает.
Учитываются все три типа событий: `pageview`, `add_to_cart`, `purchase`.
День сессии `session_date` берется из `event_date` событий DDS — это
`EventDate` источника, дата по часовому поясу счетчика. У всех событий
одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно.
SQL рассчитывает границы визитов двумя оконными функциями: первая отмечает
начало нового визита, вторая по этим отметкам присваивает событиям номер
визита. Обе используют порядок `(event_time, event_id)`: при равном времени
события идут по идентификатору. Иначе два расчета могли бы по-разному
упорядочить события и отнести отметку начала к разным строкам.
## Идентификатор
`session_id` вычисляется как `cityHash64(client_id, min(event_time))`.
При повторной сборке тех же событий посетитель и начало визита остаются
прежними, поэтому идентификатор не меняется. Счетчик или случайный UUID
выдали бы другие значения.
По `session_id` можно сопоставить сессии до и после пересборки и назвать
конкретную строку при разборе расхождения. Он не используется для сортировки
или шардирования. У событий этого поля нет; связь с заказами строится
по событиям покупки.
## Состав строки
- `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` — идентификатор запуска и время сборки строки.
В готовом SQL страницы выбираются через `argMin` и `argMax`, а UTM,
устройство и город — через `argMin`, только по `event_time`. При одинаковом
времени выбор между событиями не определен. Полный порядок окон нарезки
сам по себе эту неоднозначность не устраняет. Это ограничение учитывается
в [задании ступени 4](../../labs/4-1-session-pipeline.md).
В сессии нет суммы выручки: ее считают по заказам магазина. Клиентская
сумма покупки используется в сверке с заказами и в учебной UTM-витрине.
## Движок и наполнение
`dds.session_rep` хранит готовые сессии в `ReplicatedMergeTree` без колонки
версии. При чтении не нужно заново выполнять оконный расчет. Партиция —
раздел таблицы за один `session_date`; ключ сортировки — посетитель
и начало сессии. Читайте результат через `dds.session_v`: представление
над `dds.session_dist` явно перечисляет деловые и служебные колонки.
Источник сборки — `dds.event_v`. События, сессии и карта идентичностей
распределены по одному ключу `cityHash64(client_id)`: данные посетителя
находятся на одном шарде. Роли объектов и правило записи через `_dist`
описаны в [справочнике хранилища](../storage.md#раскладка-по-шардам).
Три SQL-файла выполняются последовательно в группе `session` дага
`dds_transform`:
1. `session_scope.sql` находит первую дату событий, за которую еще нет
сессий. Последний день для расчета — первый день событий ODS
плюс `world_position` минус один; `world_position` — номер следующего
дня для генерации. Так в расчет попадают только полностью
прожитые дни. При пустом 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#готовность-источника).
## Сверка с эталоном
Проверка `sessions_vs_reference` в `dq_check` сравнивает для каждого
посетителя и дня число строк `dds.session_v` с `uniqExact(VisitID)`
из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v`
скрывает `VisitID`.
Нулевое число расхождений подтверждает только число сессий каждого
посетителя за день. Например, ошибочное объединение двух визитов
и разделение другого на два сохранят их общее число. Поэтому в заданиях
вы дополнительно сопоставляете границы сессий по исходным событиям и проверяете страницы,
UTM, устройство и город.
## Читатели
- Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
в ее [ТЗ](../../labs/1-3-session-overview.md) (#135).
- DQ-сверка с эталоном (см. выше).
- Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда,
потом сравнивает — с эталоном и со стендовой.
## Не входит
- Механика запуска трансформации и замены партиций на кластере —
[дневной конвейер](../etl.md).
- Контур проверок качества (даг DQ, `dm.dq_summary`) — отдельный разговор
этапа 4.
- Форма витрин над сессиями — #135.
## Отклонённые варианты
- **Готовый `VisitID` вместо своей нарезки.** Сборка выродилась бы в
`GROUP BY` по чужому полю: центральный урок слоя съеден, сверка с эталоном
осталась бы без предмета. Боевой довод «Метрика теперь отдаёт `VisitID` в
хитах» честно рассказывается в лабе — после того как менти собрал сам.
- **Гибрид: сборка по `VisitID`, DQ пересчитывает нарезку.** Сторож на
нарезку, которой сборка не делает; та же работа, что в варианте «считать
сам», но урок спрятан в проверку.
- **Одно динамическое представление вместо физической таблицы.** Оконная
сессионизация на каждое чтение;
урок «слой — контракт, а не копия» уже закреплён за `dds.event_v` — доводы
модели заказа, здесь они только сильнее. Публичное `dds.session_v` поверх
готовой физической модели ничего заново не считает.
- **Счётчик или UUID вместо детерминированного хеша.** Пересборка партиции
выдала бы другие id — замена перестала бы быть идемпотентной.
- **`VisitID` колонкой в строке сессии.** Чужой чекан в своей модели: наша
сессия обязана уметь расходиться с эталоном, на этом стоит сверка;
поштучное сопоставление — лаба, не таблица.
- **Массив посещённых страниц.** Вес без читателя: вход и выход — как у
визита Метрики, топы страниц считает витрина по событиям.
- **UTM последнего касания (как в v1).** Визит атрибутируется своим первым
хитом; последнее касание — атрибуция другого уровня, не сессионного.
- **Деньги в сессии.** Выручка — только по бэкенду; клиентская сумма живёт
в сверке (спека, разделы 4 и 7).
## Связанные решения
- [Модель заказа](order.md) — прецедент замены партиций в DDS и правило
служебных колонок слоя.
- [Дока хранилища](../storage.md) — раскладка по шардам, конвенции имён,
часовые пояса.
- Мастер-спека, разделы 1.2 и 7 — `VisitID` как эталон самопроверки
(принятое отступление приёмки), карта таблиц.
- [CONTEXT.md](../../../CONTEXT.md) — «Визит», «Граница суток», «День
активности».
+297
View File
@@ -0,0 +1,297 @@
# Слой DM: витрины
Витрины готовят данные DDS для отчетов и дашбордов. Здесь описаны форма
хранения, правила обновления и проверки. Состав колонок задан в
[спецификации](../specs/2026-07-30-stand-v2-realism.md#витрины-dm)
и [учебных заданиях](../labs/README.md).
## Таблица или представление
Зерно — то, чему соответствует одна строка. Например, в заказах это заказ,
а в дневной выручке — день и категория товара. На стенде результат
с новым зерном хранится в таблице: отчет читает готовые итоги без
повторного расчета. Таблицу наполняет `dm_transform`.
У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное
представление `_v` с явным списком колонок. Для обновления по дням
нужна промежуточная таблица `_stage_rep`, в которую пишут через `_stage_dist`.
В нее записывают рассчитанные строки, пока основная таблица хранит прежние.
Затем дневные разделы основной таблицы заменяют готовыми разделами
промежуточной. Такие разделы называются партициями. Подробности — в
[справочнике хранилища](storage.md#имена-объектов).
Если запрос только переименовывает или дополняет поля и сохраняет одну
строку на событие, достаточно обычного представления. Оно выполняет
запрос при чтении, не хранит вторую копию событий и не требует пересборки.
На небольших данных стенда и агрегаты могли бы вычисляться при чтении
([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
Готовые таблицы нужны в программе для практики загрузки, обновления
и включения витрин в конвейер.
## Что готово в стенде, а что остаётся заданием
Готовы три витрины дашборда и сводка качества:
- `revenue_daily` — одна строка на день заказа и категорию товара;
образец дневной пересборки для учебных заданий.
- `purchase_vs_orders` — одна строка на заказ: сопоставление покупки
из кликстрима с заказом магазина и класс расхождения.
- `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` каждый раз добавляет всю историю новой версией
в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие
строки выбирает представление через `FINAL`.
У `purchase_vs_orders` два источника. Начало диапазона — самая ранняя
из трех дат: первая дата оформления заказа в последнем слепке, первый
пропущенный день и первый день с `awaiting_order` — покупкой, для которой
заказ пока не поступил. День ожидания проверяется отдельно: строки за него
уже есть в витрине, поэтому он не считается пропущенным.
Конец диапазона — более поздняя из последней даты заказов в слепке и последнего
прожитого дня с покупкой. Один запуск пересобирает весь отрезок,
даже если с предыдущего запуска прошло больше семи модельных дней.
Для учебной сводки брака действует отдельное правило: дни выбираются
по времени загрузки, а прежние итоги сохраняются после удаления
источника по TTL. Оно разобрано в
[задании `dq_errors_daily`](../labs/1-4-dq-errors-daily.md).
## Витрины стенда
Представления `_v` явно перечисляют поля для отчетов, а также `_load_id`
и `_load_ts` — идентификатор запуска и время записи. Новая колонка
физической таблицы появится в представлении только после изменения его SQL.
### `dm.revenue_daily_v`
Одна строка содержит итоги оплаченных заказов за день по категории товара.
Запрос читает `dds.order_v` и разворачивает массивы артикулов, количеств
и цен в строки позиций заказа. Артикул, количество и цена с одним индексом
становятся одной строкой. Категория артикула берется из `dic.products`.
`orders` считает разные заказы: несколько позиций одной категории
в заказе не увеличивают этот счетчик. `units` — сумма количества товаров,
`revenue` — сумма произведений цены на количество. Скидка и доставка
в нее не входят. `aov` равен `revenue / orders` для того же дня и категории
с округлением до двух знаков после запятой.
Первый запуск рассчитывает выручку за всю доступную историю. Следующие
берут диапазон дат оформления заказов из последнего слепка ODS.
Если в 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`
Одна строка сопоставляет события покупки с заказом магазина. События
из `dds.event_v` группируются по номеру заказа `purchase_id[1]`:
по формату источника у покупки один номер и одна заявленная сумма.
Отдельный счет событий позволяет обнаружить повтор покупки
и присвоить класс `duplicate_event`. Заказы берутся из `dds.order_v`.
Полное соединение `FULL OUTER GLOBAL JOIN` сохраняет и покупки без заказа,
и заказы без покупки. Обе стороны записаны подзапросами: при прямом чтении
левой Distributed-таблицы каждый непарный заказ повторился бы по числу шардов
([исследование](../research/2026-08-22-distributed-join.md)).
Если для строки нет пары, поля отсутствующей стороны получают `NULL`
благодаря `join_use_nulls = 1`. При `join_use_nulls = 0` они заполнились бы
нулями или пустыми строками, которые сложнее отличить от данных источника.
Клиентская `declared_revenue` и серверная `items_total` сравниваются
как `Decimal(18, 2)`. Классы проверяются по порядку:
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
события остается отмененным, а не потерянным.
Партиции витрины разделены по `order_day`. Основная и промежуточная таблицы
распределяют строки по `cityHash64(order_id)`. Запрос рассчитывает весь
выбранный диапазон и одной вставкой записывает строки в промежуточную
таблицу. Затем ее дневные разделы заменяют разделы основной таблицы.
Первичная сборка охватывает всю историю обоих источников;
следующие прогоны выбирают дни по правилам обоих источников, описанным выше.
### `dm.daily_traffic_v`
В строке один день. `visitors` считает разные `client_id` из
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
Несколько кук, связанных с одним пользователем магазина, учитываются
в `visitors` по отдельности, а в `known_users` дают одного пользователя.
Поэтому `known_users <= visitors`.
Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3
чтение через публичные `_v` с `distributed_product_mode = 'local'`
не дало локального соединения. Поэтому в запросе явно указан
`GLOBAL LEFT JOIN`. Он сохраняет сессии без известного пользователя;
в `known_users` учитываются только ненулевые `user_id`.
Обновление всей истории новой версией описано в разделе
[«Ритм пересборки»](#ритм-пересборки).
## `dm.dq_summary`
Одна строка `dm.dq_summary_v` — день данных и одна проверка.
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
Деловые колонки сводки:
- `data_date` — проверяемый день;
- `check_name` — имя проверяемого правила;
- `reference_rows` — число деловых ключей на эталонной стороне;
- `actual_rows` — число деловых ключей в проверяемом объекте;
- `failed_rows` — число отсутствующих или различающихся ключей;
- `status` — `pass` при нуле нарушений, иначе `fail`.
В `_load_id` и `_load_ts` записаны идентификатор запуска и время расчета
сводки. Даже одно расхождение дает `fail`.
## Звено DQ конвейера
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
правил:
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
`dds.session_v`;
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
`orders` в паспорте мира, объект — число заказов класса в
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
`duplicate_event` и `amount_delta` только для покрытых паспортом закрытых
дней;
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
поля сверки сравниваются с независимой сборкой из `dds.event_v` и
`dds.order_v`;
- `daily_traffic_vs_dds`: ключ — день. `visitors` и `known_users`
сравниваются с прямыми агрегатами `dds.session_v` и
`dds.identity_map_v`.
Каждая проверка берет дни и ключи из обоих сравниваемых наборов данных.
Поэтому потерянная строка остается видна на другой стороне сравнения.
Расхождения считаются по ключам: завышение одного показателя не скроет
занижение другого в общей сумме за день.
`classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая
проверка доставки и обработки эталонных данных стенда. Она не проверяет
сам генератор; у настоящего магазина такого эталона нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
сводку и несколько примеров расхождений. Техническая сверка с паспортом читает небольшой
файл JSON в Python. Результаты всех пяти проверок записываются в общую
промежуточную таблицу. Когда расчеты завершились, ее дневные разделы
заменяют разделы `dm.dq_summary_rep`. Затем отдельные задачи читают
сохраненную сводку и завершаются ошибкой при `failed_rows > 0`.
Так расхождение сохраняется в таблице до отказа задачи. Если сам расчет
не выполнен, прежняя сводка остается на месте. Задача с расхождениями
выводит в журнал не больше двадцати ключей со значениями обеих сторон. Для полного списка итоговую агрегацию SQL-сверки
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
business_key`.
Если день исчез из обоих сравниваемых наборов, новый расчет не создаст
для него строк. Даг отдельно удаляет прежний результат за этот день
из сводки.
## Даг самопроверки
`homework_check` запускается вручную и содержит задачу для каждого из
[пяти заданий по витринам](../labs/README.md). Если витрины нет или значения
расходятся с источником, задача завершается ошибкой. Журнал показывает
причину и примеры несовпадений. Успех означает, что проверяемые значения
совпали; остальные требования задания вы проверяете сами.
Ожидаемые значения рассчитываются по исходным данным текущего стенда.
Для четырех заданий это события или сессии DDS, для сводки брака —
таблицы ошибок ODS. Сравнение по деловому ключу учитывает отсутствующие
строки с обеих сторон. Результаты не пишутся в `dq_summary`:
самопроверка учебных витрин отделена от качества готового конвейера.
Готовые SQL-проверки открывайте после собственного решения: они содержат
расчет существенной части задания.
## Отклонённые варианты
- **Весь слой представлениями.** Дёшево и всегда свежо, но главный
производственный паттерн слоя — пересборка агрегатов — остался бы
невидим, а `dm_transform` — без работы.
- **Материализовать всё.** Копия событий ради переименования колонок —
лишняя; такой пример приучал бы без нужды дублировать исходные данные.
- **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах
стенда нет: сторож без настоящей опасности.
- **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до
выполнения лаб, а проверка, которая всегда красная, приучает себя
игнорировать.
- **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого
состояния; изображать его логами — семантика, которую пришлось бы
расшифровывать. Красное «ещё не построено» честнее.
- **Самопроверка пишет в `dq_summary`.** Результаты учебных попыток смешались бы
с историей качества конвейера.
- **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
бы число известных на момент закрытия — витрина тихо расходится с картой
и протаскивает обратно «знание на дату», отвергнутое развилкой карты
(#133).
- **Инкрементальный пересчёт затронутых дней по `first_matched_at`.**
Производственный ход, но на объёмах стенда его экономия неизмерима, а
след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой
повышенной сложности — issue #149.
- **Superset поверх DDS без слоя DM.** Терялся бы урок «витрина — контракт
под потребителя», против раздела 7 спеки.
## Связанные решения
- [Мастер-спека](../specs/2026-07-30-stand-v2-realism.md), разделы 4 и 7, —
состав колонок витрин и классы сверки.
- [Дневной конвейер](etl.md) — запуск `dm_transform`, объём прогона,
замена партиций на кластере.
- [Дока хранилища](storage.md) — имена объектов, конвенция чтения через `_v`,
партиция-донор.
- [Карта модели DDS](dds/README.md) — источники витрин.
- Резолюция — тикет #135 карты #127.
+198
View File
@@ -0,0 +1,198 @@
# Дневной конвейер трансформаций
Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет
результат. Здесь описаны запуск, выбор дней и запись результата в таблицы.
Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md).
## Форма
`etl_pipeline` последовательно запускает `dds_transform`, `dm_transform`
и `dq_check`. Каждый следующий даг начинается после успеха предыдущего.
Для этого `TriggerDagRunOperator` ждет завершения запущенного им дага.
Если тот завершился ошибкой, задача ожидания в конвейере тоже завершится
ошибкой.
Поведение проверено через Context7 по документации Airflow 3.x (3.1.6).
У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора
можно запустить даг слоя вручную. У каждого разрешен один активный запуск:
два запуска одного дага используют одни промежуточные таблицы и могли бы
стереть результаты друг друга. Расчет каждой модели выполняется отдельной
задачей или группой задач.
## Три пути запуска
```text
world_initialize ─┐
world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transform ─> dq_check
запуск рукой ─────┘
world_next_day ─────> источники без ETL
```
- `world_live_day` запускает конвейер после успешной генерации, приема
заказов и обновления `world_position`. Если трансформация упала, день
уже прожит и позиция не откатывается. После исправления причины
запустите `etl_pipeline` вручную.
- `world_next_day` отправляет дни пачкой и ждет приема заказов, но конвейер
не запускает. После него запустите `etl_pipeline` сами.
- `world_initialize` запускает конвейер последним шагом: при успешном
первом `make up` начальные данные уже собраны в DDS и DM.
## Объём прогона
Объем прогона — дни, которые обрабатывает один запуск. Даты передавать
не нужно: даги определяют их по состоянию таблиц и `world_position`.
Например, после генерации семи дней без ETL один запуск рассчитает сессии
за все семь дней.
Для расчета сессий выбираются дни от первого, за который в DDS еще нет
сессий, до последнего прожитого.
`world_position` хранит номер следующего дня для генерации, поэтому
последний прожитый день равен `min(EventDate) + world_position - 1`.
Начало календаря берется из ODS; события ранних дней там не удаляются по TTL.
Один `max(EventDate)` не годится: события за день есть и во время его
генерации, когда последние визиты еще не закончились.
Точный запрос — [`session_scope.sql`](../../sql/dds/session_scope.sql).
Для заказов диапазон задает последний принятый слепок ODS. Среди строк
с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя
даты оформления заказа. Состояние заказов за эти дни еще может измениться.
Если в ODS есть заказы за более ранний день, а в DDS за него нет строк,
начало диапазона сдвигается до этого дня. Запрос заново рассчитывает
заказы за все дни выбранного отрезка.
Позиция мира для выбора дней заказов не подходит: с событиями дня D
приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS;
их наличие еще не доказывает полноту слепка.
Карта идентичностей пересчитывается целиком. Витрины выбирают дни
по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки).
Если за день сохранилась хотя бы одна строка, запрос не считает его
пропущенным. Полноту данных проверяют отдельно.
## Готовность источника
Перед сборкой DDS задача `wait_for_source` последовательно ждет двух
условий:
1. Группа `clickstream_hits` дочитала оба раздела топика `hits`.
Подтвержденные смещения сравниваются с верхними смещениями Kafka.
Группа и смещения должны существовать, суммарное отставание — равняться нулю.
2. В `system.distribution_queue` у `ods.event_dist` не осталось файлов
доставки на шарды. Запрос через `clusterAllReplicas` читает обе ноды.
Опросы идут с паузой в три секунды; на обе стадии задан общий срок
300 секунд. Журнал показывает смещения и отставание Kafka, затем число
файлов и `last_exception` очереди на каждой ноде. Если готовность не
подтверждена за срок ожидания, задача завершается ошибкой.
Одного нулевого отставания Kafka мало: сообщение может быть подтверждено,
пока Distributed-таблица еще доставляет его на шард. Успех `orders_ingest`
тоже не заменяет эти проверки: у заказов другой топик и потребитель.
Эти условия проверяют доставку на момент опроса. Они не останавливают
поступление новых событий и не сверяют содержимое таблиц. Поэтому
`world_position` отдельно ограничивает сборку сессий прожитыми днями,
а качество результата проверяет `dq_check`.
## Идемпотентность
Идемпотентность означает, что повторная обработка тех же исходных данных
не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts`
при новой сборке меняются.
Результат дневного расчета сначала записывается в промежуточную таблицу.
Она нужна, чтобы читатели продолжали видеть прежние данные, пока новые
еще рассчитываются. Например, для сессий это `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`. Старые версии
могут физически оставаться до фонового слияния; повторяемость относится
к результату чтения, а не к числу строк на диске.
## Чего конвейер о себе не знает
Трансформация собирает слой по правилу, а не сверяет его с источником. Отсюда
три вещи, которых она не заметит, и у всех трёх один адрес — контур DQ.
- **Часть последнего слепка не доехала.** Отказ вставки после коммита офсетов
уносит прочитанное с собой. Пропади так строки самого старого дня окна —
окно, выведенное из ярлыков, молча сузится, и день не пересоберётся
([`sql/dds/order_scope.sql`](../../sql/dds/order_scope.sql)).
- **Замена партиции прошла на одном шарде из двух.** День в слое есть — значит,
объём следующего прогона его не тронет, а слой останется собранным наполовину
из старого, наполовину из нового.
- **Брак внутри `items` становится нулями.** Строгий приём внутрь позиций не
смотрит, разбор по типу молчит, и цена без числа доедет до витрины выручки
нулём ([`sql/dds/order_rebuild.sql`](../../sql/dds/order_rebuild.sql)).
Общее у них одно, и оно же подсказывает лекарство: ответить может только
сверка слоя с источником по дню — счёт заказов и позднейший `updated_at` с
обеих сторон. Трансформации такую сверку не поручают намеренно: она собирает,
а проверяет другой. Так две роли остаются различимы, и ни одна не
притворяется другой.
## Не входит
- Партиционная переобработка — переиграть произвольный прошлый день или
диапазон, включая форму необязательных параметров того же дага.
- Инкрементальный ETL вместо пересборки отклонен (#2, wontfix). Идея отдельной
лабы на эту тему — #149.
- Состав витрин `dm_transform` и проверок `dq_check` —
[дока слоя DM](dm.md).
## Отклонённые варианты
- **Задачи трансформаций прямо в шаговых дагах.** Один зелёный прогон —
весь день, но даги времени отвечают за ось времени: шаговых дагов два, шаги
дублировались бы, а этапу 5 пришлось бы выдирать их обратно.
- **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с
Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом
режиме слои копили бы отставание молча.
- **Временный даг с нейтральным именем до этапа 5.** Дневная сборка вперёд
никуда не денется, переезд был бы переименованием ради выдуманной границы.
- **Диапазон дней параметрами триггера.** Ручному запуску менти параметры
передавать некому, а перезапуск после падения требовал бы помнить conf
упавшего прогона.
- **Принять гонку с хвостом `hits`.** Дыра всплыла бы в DQ-сверке, а
чиниться — не раньше этапа 5.
## Связанные решения
- [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не
переживает отказ выше: отсюда ожидания в триггерах.
- [ADR 0009](../adr/0009-world-control.md) — даги модельного времени: их шаговый
даг живого дня и дёргает конвейер.
- [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему
прикладным процессом владеет Airflow и почему `world_initialize` кончается
собранными слоями.
- Резолюция — тикет #134 карты #127; что пересобирает каждая сущность —
[заказ](dds/order.md), [сессия](dds/session.md),
[карта идентичностей](dds/identity.md).
+59
View File
@@ -0,0 +1,59 @@
# Генератор кликстрима
Путеводитель показывает, как пройти генератор от корневого зерна до двух
выходов дня, не пересказывая код и спеки.
## Целевая картина одним взглядом
- **Подпоток определяется местом в дереве, а мир внутри подпотока — порядком
бросков** — [дерево зерен и порядок бросков](seeds.md).
- **Мир поднимает только когорты окна, нужные запрошенному дню** —
[ленивость мира](lazy-world.md).
- **Устройство и город принадлежат куке на всю жизнь и потому решаются
планом состава** — [паспорт куки](cookie-passport.md).
- **Выбор по весам сведен к целым броскам и арифметике интервалов** —
[целочисленная случайность](randomness.md).
- **Торговля получает готовые строки страниц и возвращает поток событий
вместе с покупками для бэкенда** — [шов торговых событий](commerce-seam.md).
- **Сутки принадлежат поясу счетчика, а `UTCEventTime` остается абсолютной
меткой** — [время модельного дня](counter-time.md).
```mermaid
flowchart LR
seed["Корневое зерно"] --> streams["Подпотоки"]
streams --> cohorts["Когорты"]
cohorts --> audience["Аудитория дня"]
audience --> visits["Визиты"]
visits --> output["Выход day.stream"]
output --> events["Поток событий"]
output --> backend["Заказы дня<br/>orders.of_day"]
```
За правой ветвью начинается отдельный набор документов —
[устройство заказов](../orders/README.md).
## Живой проход
Из каталога `generator/` одна команда собирает канонический D0 и показывает
все стадии на одном экране:
```sh
make demo
```
Когорту и аудиторию пример получает через открытый интерфейс плана, а визиты
считает группировкой выходных строк по `VisitID`. Код прохода —
[`demo.main`](../../../generator/src/clickstream_generator/demo.py).
## Родословная
Устройство генератора и основания решений записаны в [спеке этапа
2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки —
в [мастер-спеке стенда](../../specs/2026-07-30-stand-v2-realism.md).
Основания трех решений записаны в
[ADR 0015](../../adr/0015-counter-timezone.md),
[ADR 0016](../../adr/0016-cookie-passport.md) и
[ADR 0017](../../adr/0017-order-numbering.md), а спека этапа 2 закрыта.
Путеводитель появился по тикету
[#48](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/48):
он отвечает на другой вопрос — как пройти принятое устройство по коду.
@@ -0,0 +1,34 @@
# Шов торговых событий
Этот документ отвечает на вопрос, что день передает торговой части и что
получает от нее назад.
[`day.stream`](../../../generator/src/clickstream_generator/day.py) сначала
собирает выровненные по строкам ряды трафика:
- `columns` — готовые колонки просмотров страниц;
- `page` — вид страницы магазина;
- `product` — номер товара у карточки;
- `person` — человек за кукой строки.
`page` указывает, куда может сесть торговое событие, а `product` — какой товар
посетитель действительно открыл. `person` в кликстрим не выходит: он нужен
только для второй проекции покупки — заказа бэкенда.
[`commerce.weave`](../../../generator/src/clickstream_generator/commerce.py)
принимает эти ряды, вплетает `add_to_cart` и `purchase`, снова упорядочивает
поток и возвращает обновленные `columns`, `page`, `product` вместе с
[`commerce.Purchases`](../../../generator/src/clickstream_generator/commerce.py).
`Purchases` — узкая передача следующей стороне. В ней лежат номер заказа,
купленные товары и количества, клиентская выручка, промокод, момент события и
человек. Все они получены из тех же покупок, что уехали в поток, а не разобраны
назад из JSON.
[`orders.of_day`](../../../generator/src/clickstream_generator/orders.py)
принимает `Purchases`, а [`day.Day`](../../../generator/src/clickstream_generator/day.py)
отдает заказы рядом с колонками событий. Здесь поток дня расходится на две
ветви, показанные в индексе.
За этой границей начинается [устройство заказов](../orders/README.md). Судьба,
слепки и деньги бэкенда описаны там.
@@ -0,0 +1,29 @@
# Паспорт куки
Этот документ отвечает на вопрос, почему паспорт куки решает план состава, а
день только разворачивает его в события.
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) получает
случайность через
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
и вызывает [`plan._passports`](../../../generator/src/clickstream_generator/plan.py)
в ветви «состав мира». Это не подпоток отдельного дня.
`plan._passports` выбирает строки устройства и города из справочников. У
двухкуковой пары город первой куки переносится во вторую, а устройство
выбирается из другой половины профилей. Сведения о парах есть только у плана.
Если независимо вывести паспорт каждой куки из `ClientID`, один человек может
оказаться в двух городах — это будет ложью в данных.
[`plan.Cohort`](../../../generator/src/clickstream_generator/plan.py) хранит
номера устройства и города. `Cohort.visitors_on` переносит их в
[`plan.DayAudience`](../../../generator/src/clickstream_generator/plan.py), а
[`day.stream`](../../../generator/src/clickstream_generator/day.py) разворачивает
профили в колонки событий. День не бросает паспорт заново, поэтому кука
сохраняет его на всю жизнь.
Броски паспорта стоят после прежнего состава когорты. Правило такого
продолжения подпотока описано в [дереве зерен](seeds.md), а выбор
паспорта объясняет [ADR 0016](../../adr/0016-cookie-passport.md). Зачем стенду
нужна двухкуковая пара одного человека, фиксирует
[мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5.
@@ -0,0 +1,40 @@
# Время модельного дня
Этот документ отвечает на вопрос, почему дата модельных суток не обязана
совпадать с UTC-датой события.
В коде встречаются три координаты времени:
- D — номер дня на оси модельного времени;
- `EventDate` — дата D в поясе счетчика;
- `UTCEventTime` — абсолютная метка события.
Местное время гостя задает форму активности внутри суток, а границу дня задает
пояс счетчика.
[`world.ORIGIN`](../../../generator/src/clickstream_generator/world.py) — D0
календарной оси: он переводит номер D в конкретную дату. Это не механизм
часового пояса. Механизм —
[`world.COUNTER_TIMEZONE_MINUTES`](../../../generator/src/clickstream_generator/world.py),
смещение пояса счетчика от UTC.
В [`day._columns`](../../../generator/src/clickstream_generator/day.py)
`EventDate` получается как `ORIGIN + D`. Чтобы получить абсолютную полночь
тех же суток, код вычитает смещение счетчика, а затем прибавляет секунды
события внутри дня. Поэтому сутки `2026-06-01` в UTC+4 начинаются в
`2026-05-31 20:00:00Z`: у первых четырех часов
`toDate(UTCEventTime) != EventDate`.
[`day._starts`](../../../generator/src/clickstream_generator/day.py) сначала
выбирает час из городской строки весов, собранной
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py),
а затем равномерно выбирает секунду внутри часа. Строка поворачивает суточную
волну на разницу между поясом гостя и поясом счетчика. Так вечерний пик гостя
попадает в нужные часы внутри дня, но граница суток и `EventDate` остаются
счетчиковыми.
Как часовой пояс меняет представление времени в ClickHouse, описано в разделе
«Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл
полей записаны в [описании события](../../formats/clickstream-event.md).
Выбор модели времени объясняет
[ADR 0015](../../adr/0015-counter-timezone.md).
+26
View File
@@ -0,0 +1,26 @@
# Ленивость мира
Этот документ отвечает на вопрос, как любой день собирается без прожитой
истории и без глобального списка посетителей.
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) строит
одну когорту по зерну и дню ее появления. Отрицательные дни обозначают
предысторию; ее глубину ограничивает
[`world.RETURN_TAIL_DAYS`](../../../generator/src/clickstream_generator/world.py).
Запоминание уже рассчитанных когорт ускоряет соседние вызовы, но не становится
состоянием мира: очистка кеша не меняет результат.
[`plan.audience`](../../../generator/src/clickstream_generator/plan.py) для
дня D спрашивает `Cohort.visitors_on(D)` у когорт от
`D - RETURN_TAIL_DAYS` до D и соединяет полученные части. Поэтому вызов для
D0 поднимает всю предысторию в окне — 91 когорту от D−90 до D0.
Когорты предыстории дают D0 вернувшихся посетителей, но собственных событий
не порождают. Ось событий начинается в D0. Окно активности отсчитывается от
первого дня человека и остается общим для обеих его кук; за краем окна
когорта больше не может попасть в аудиторию.
Отсюда самостоятельность дня: D500 и D5 заглядывают назад на одинаковое число
когорт. Ни плану состава, ни день-функции горизонт мира не нужен.
Зачем приняты предыстория и ограниченное окно, объясняет [спека
генератора](../../specs/2026-08-01-generator.md), раздел 1.
+41
View File
@@ -0,0 +1,41 @@
# Целочисленная случайность
Этот документ отвечает на вопрос, как читать целочисленный выбор по весам и
зачем генератору свой маленький `weights.py`.
За `PCG64` и целые броски `Generator.integers` отвечает NumPy.
Генератор стенда не пользуется готовым выбором по вещественным вероятностям и
плавающими распределениями: их алгоритмы и системная математика могут
различаться между версиями и архитектурами. Зависимости закреплены, а броски
остаются целыми — так выполняется побайтовое обещание, записанное в [спеке
генератора](../../specs/2026-08-01-generator.md), раздел 2.
## Одна таблица весов
[`weights.pick`](../../../generator/src/clickstream_generator/weights.py)
сначала получает накопленные веса. Для весов `2, 3, 1` это границы
`2, 5, 6`. Затем он бросает целое число из полуинтервала `[0, 6)` и находит
первую границу справа:
- броски `0–1` выбирают вариант 0;
- броски `2–4` выбирают вариант 1;
- бросок `5` выбирает вариант 2.
Размер участка и есть вес варианта. Нормировать веса до долей единицы не
нужно.
## Своя таблица для каждой строки
[`weights.pick_row`](../../../generator/src/clickstream_generator/weights.py)
работает так же, когда у каждого броска свой ряд весов. Ряды с одинаковой
суммой укладываются друг за другом. Номер ряда смещает бросок на его участок,
единый поиск находит ячейку, а обратное смещение возвращает номер варианта.
Конкретный проход виден в
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py)
и [`day._starts`](../../../generator/src/clickstream_generator/day.py): для
каждого города суточная волна повернута в свой ряд, но сумма весов от поворота
не меняется.
При чтении вызова `pick` полезно представлять ряд целочисленных участков:
бросок выбирает вариант, в чей участок он попал.
+61
View File
@@ -0,0 +1,61 @@
# Дерево зерен и порядок бросков
Этот документ отвечает на вопрос, почему место подпотока стабильно, а порядок
бросков внутри него менять нельзя.
## Два порядка
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
и [`seeds.day_stream`](../../../generator/src/clickstream_generator/seeds.py)
находят ветвь по позиции `SeedSequence(entropy, spawn_key)`. Поэтому результат
ветви не зависит от того, какую соседнюю ветвь вычислили раньше.
Внутри найденной ветви живет один `Generator`. Каждый его вызов забирает
следующие значения. Поэтому порядок вызовов меняет результат: прежний
пятый бросок после вставки становится шестым и достается другому свойству
мира.
Дерево целиком описано в модуле
[`seeds`](../../../generator/src/clickstream_generator/seeds.py), а
[`seeds.Component`](../../../generator/src/clickstream_generator/seeds.py)
задает его нижний уровень — четыре подпотока дня:
```text
корневое зерно
├── состав мира
│ ├── ось событий → номер дня → когорта
│ └── предыстория → глубина → когорта до D0
└── дни
└── номер дня
├── TRAFFIC
├── COMMERCE
├── ORDERS
└── BEACONS — потеря и повторная отправка событий
```
Отрицательному дню нельзя отдать отрицательную позицию, поэтому предыстория
получила отдельную ветвь. Компоненты дня тоже занимают позиции, а не получают
зерна в порядке запуска.
## Что ломает вставка
Новый бросок в середине функции сдвигает все последующие значения этого
подпотока. Код продолжает работать, но тем же свойствам достается другая
случайность. Соседние подпотоки остаются прежними, а измененная часть
канонического мира проявляется в паспорте и побайтовом сравнении.
Какое обещание воспроизводимости это защищает, записано в
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
различать два свойства: `SeedSequence` стабилизирует адрес ветви, но порядок
бросков внутри `Generator` стабилизирует только сам код.
## Броски последними
Новое свойство продолжает существующий подпоток после прежних бросков. Такой
бросок не отнимает значения у уже рассчитанных свойств. В
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) паспорт
куки был дописан после состава и назначенных заказов, а личность человека —
после паспорта. Поэтому прежние ряды остались прежними.
Почему паспорт вообще принадлежит этой ветви, разобрано отдельно в
[документе о паспорте куки](cookie-passport.md).
-205
View File
@@ -1,205 +0,0 @@
# Мониторинг: из чего собран и что скребёт
Документ описывает зону мониторинга: какие сервисы в неё входят, что собирает
Prometheus, какие источники данных подготовлены в Grafana, каким пользователем
она ходит в ClickHouse и что показывают собранные дашборды. Зачем зоне именно
такой состав и какие дашборды на нём задуманы — [ADR
0002](../adr/0002-monitoring-scope.md); устройство доступа — [ADR
0007](../adr/0007-clickhouse-access.md).
**Что здесь есть и чего ещё нет.** Собран пол: свой образ Grafana с плагином
ClickHouse, два подготовленных источника данных, экспортёр метрик Kafka и цель
для него у Prometheus. Поверх пола стоит главный дашборд — «Данные»; «Кластер»
и «Запросы» приносят отдельные тикеты. Правил оповещения и серверного журнала
`system.text_log` тоже пока нет.
## Состав
| Сервис | Образ | Что делает |
|---|---|---|
| `prometheus` | `prom/prometheus:v3.13.2` | опрашивает цели раз в 15 секунд, порт стенда `29090` |
| `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` |
| `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` |
Плагин ClickHouse 4.20.0 входит в образ Grafana. Фоновая установка плагинов
при запуске отключена.
## Что скребёт Prometheus
| Цель | Задание | Метка `role` | Откуда метрики |
|---|---|---|---|
| `clickhouse-01:9363` | `clickhouse` | `server` | встроенный экспортёр ноды |
| `clickhouse-02:9363` | `clickhouse` | `server` | встроенный экспортёр ноды |
| `clickhouse-keeper:9363` | `clickhouse-keeper` | `keeper` | встроенный экспортёр keeper |
| `kafka-exporter:9308` | `kafka` | `exporter` | внешний экспортёр |
Ровно эти четыре цели пересчитывает `check_prometheus_targets` в
`scripts/stand-smoke.sh` — и по значению `up`, и по списку адресов. Пятая цель
красит смоук; это и есть способ заметить, что состав пола поменяли молча.
## Источники данных Grafana
Оба источника подготовлены файлами в `infra/grafana/provisioning/datasources/`
и объявлены `editable: false`: править их через интерфейс нельзя, источник
истины — репозиторий.
- `prometheus` — основной по умолчанию, ходит на `http://prometheus:9090`.
- `clickhouse` — плагин `grafana-clickhouse-datasource`, нативный протокол,
`clickhouse-01:9000`, пользователь `grafana`, пароль из `.env`.
Источник ClickHouse смотрит на `clickhouse-01`. Системные таблицы обеих нод
панели читают через `clusterAllReplicas`, как принято в ADR 0002.
## Доступ к ClickHouse
Grafana ходит пользователем `grafana` с ролью `monitoring_reader`: читает слои,
таблицы базы `dic` и системные таблицы. `REMOTE` разрешает
`clusterAllReplicas`, `CLUSTER` — запросы `ON CLUSTER`. Решение о своей роли
записано в [ADR 0007](../adr/0007-clickhouse-access.md). Пароль задаётся
переменной `CLICKHOUSE_GRAFANA_PASSWORD`.
## Куда кладутся дашборды
Каталог `infra/grafana/provisioning/dashboards/` смонтирован в контейнер и
объявлен провайдером в `provider.yml`: Grafana подхватывает оттуда файлы
дашбордов, удалять и править их через интерфейс запрещено. Новый дашборд — это
новый файл в каталоге и больше ничего: в `compose.yaml` заходить не нужно, ради
этого каталог и заведён заранее.
Файлы каталога разбирает `make config-test`, рядом с файлами дагов и по тому же
доводу ([testing.md](testing.md)).
## Дашборд «Данные»
`infra/grafana/provisioning/dashboards/data.json` — шесть панелей на один
экран. Вопрос у экрана один: с потоком всё в порядке? Состав панелей и довод за
каждую — [ADR 0002](../adr/0002-monitoring-scope.md).
Порогов и цветовых вердиктов на панелях нет: на дашборд смотрят глазами,
красное и зелёное живёт в проверках и дагах DQ.
| Панель | Откуда берёт | О чём отвечает |
|---|---|---|
| Свежесть | `ods.event_dist`, `ods.order_dist` | сколько прошло с последней доехавшей строки |
| Поток | `stg.hits_raw_dist`, `stg.orders_raw_dist` | сколько строк приезжает в слой сырья |
| Брак | те же и `ods.*_errors_dist` | сколько приехавшего не разобралось и какая это доля |
| Сходимость | экспортёр Kafka и `stg.hits_raw_dist` | произведённое против приземлившегося |
| Отставание | `kafka_consumergroup_lag` | сколько сообщений не прочитано |
| Дневной слепок | `stg.orders_raw_dist` | какой слепок доехал, когда и после какой паузы |
### Почему слои у панелей разные
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань
разбор — свежесть ODS это покажет, свежесть сырья нет.
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
здесь про доставку: местом диагностики доставленных версий физическая таблица
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего
не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события,
и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
сильнее, чем дольше живёт стенд.
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
отдельная куча, а часть сырья, которая не прошла разбор.
Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
приехавшего и однажды перевалила бы за сотню процентов.
Панель дневного слепка, наоборот, окна не знает. Её вопрос про состояние, а не
про отрезок, и последние десять слепков она показывает всегда.
Отдельного ряда «против вчерашнего» на панели потока нет, и это решение, а не
пропуск. Реальное «вчера» на стенде чаще всего приходится на время, когда
стенда ещё не было. А сдвинуть ряд на модельные сутки значит записать в JSON
дашборда ускорение мира — ровно ту пометку про тик, которую [ADR
0002](../adr/0002-monitoring-scope.md) в дашборде держать не велит: поменяют
ускорение, и панель молча соврёт. Сравнение остаётся глазу: модельные сутки
укладываются в минуты реального времени, и соседние дни мира помещаются в одно
окно панели сами.
### Чтецов Kafka панели не читают
`stg.hits_raw_kafka` и `stg.orders_raw_kafka` — таблицы движка Kafka, а не
хранилище: запрос из панели у второго забрал бы слепок себе и сдвинул офсеты
(`kafka_commit_on_select`, см. `sql/ddl/10-stg-tables.sql`), а даг приёма
приехал бы к пустому топику. Приземлившееся панели считают по `stg.*_raw_dist`,
произведённое — по метрикам экспортёра.
### Как считается сходимость
Произведённое живёт в Kafka, приземлившееся — в ClickHouse, и со своей стороны
ни одна из них не видит другую. Поэтому панель смешанная: ряд «произведено в
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
Единицы общие — сообщений за интервал панели. Со стороны Kafka это разность
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
стороны ClickHouse — `count()` строк сырья по тому же интервалу.
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
растягивает приращение на всё окно и на минутном интервале промахивается на
проценты: здоровый стенд выглядел бы расходящимся. Накопленный итог от начала
окна точнее обоих приростов, но отсчитывать его надо от края окна — а на
свежем стенде окно начинается раньше, чем появился экспортёр, и ряд пропадает
целиком.
Столбцы ClickHouse подписаны правым краем, а не левым. Точка Prometheus — это
прирост к моменту времени, а `count()` по интервалу — сумма, которая набирается
к его концу; подпиши мы её началом, здоровые ряды разъехались бы ровно на один
столбец. Шаг панели при этом не мельче минуты: экспортёр опрашивают раз в
пятнадцать секунд, и на более мелком шаге ряд Kafka пошёл бы ступеньками там,
где ряд ClickHouse идёт гладко.
Точка в точку ряды всё равно не совпадут, и ждать этого не надо: опрос
экспортёра не попадает на границы столбцов, поэтому на разгоне потока линия
Kafka идёт чуть выше. Читается устойчивый разрыв между линиями, а не разница в
один столбец.
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден
сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему
другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него
отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё
он приезжает полем `snapshot_date`.
## Что проверено
Права роли `monitoring_reader` сверены 21 августа 2026 года по документации
ClickHouse через MCP Context7; разбор — в
[ADR 0007](../adr/0007-clickhouse-access.md), раздел «Что проверено».
Сам состав проверен на живом стенде 21 августа 2026 года, собранном заново
после `make clean`.
- Grafana видит плагин `grafana-clickhouse-datasource` 4.20.0
(`/api/plugins?type=datasource`), оба подготовленных источника здоровы.
- Запрос источника ClickHouse к `system.one` через `clusterAllReplicas` вернул
2 — с одной ноды видны обе.
- Prometheus видит ровно четыре цели, все со значением `up = 1`; запрос
`kafka_consumergroup_lag` вернул ряды по группам `clickstream_*`.
- `make smoke` 21/21, `make check-clickhouse` 10/10, `make check-services` 7/7.
Дашборд «Данные» проверен на живом стенде в тот же день, `world_live` снят с
паузы.
- Дашборд поднимается из каталога сам: файл положен, панели появились без
ручного импорта. Сохранение и удаление через API отвергнуты («Cannot save
provisioned dashboard», «provisioned dashboard cannot be deleted»).
- Все шесть панелей отвечают через `/api/ds/query` — и в окне часа по
умолчанию, и в окне на шесть часов, которое начинается раньше, чем поднялся
экспортёр. Пустых нет ни при идущем потоке, ни при остановленном.
- Брак: подсунутое в топик `hits` негодное сообщение легло в
`ods.event_errors` с классом `keyset_mismatch`; панель показала единицу и
долю 0,039 % в десятиминутном окне. Строка убрана мутациями из
`stg.hits_raw_rep` и `ods.event_errors_rep`, обе таблицы вернулись к нулю.
- Сходимость: на живом потоке за двадцать пять минут медиана расхождения между
рядами 3 %, худший столбец 11 % — на переломе потока, как и описано выше.
Отказы тоже по замеру: `increase` расходился сильнее, а накопленный итог
пропадал целиком, стоило окну начаться раньше экспортёра.
- Свежесть реагирует на остановленный поток и возвращается. Через пять минут
после конца модельного дня панель показала 305 с у событий и 288 с у
заказов; стоило пустить следующий день, события вернулись к секунде.
Свежесть заказов при этом растёт до конца дня и садится, когда доедет его
слепок, — так и устроен второй источник.
+104
View File
@@ -0,0 +1,104 @@
# Мониторинг: из чего собран и за чем следит
Набор описывает зону мониторинга: какие сервисы в неё входят, что собирает
Prometheus, какие источники данных подготовлены в Grafana, каким пользователем
она ходит в ClickHouse и куда кладутся дашборды. Устройство каждого дашборда —
отдельным файлом, указатели ниже. Зачем зоне именно такой состав и какие
дашборды на нём задуманы — [ADR 0002](../../adr/0002-monitoring-scope.md);
устройство доступа — [ADR 0007](../../adr/0007-clickhouse-access.md).
**Что здесь есть и чего ещё нет.** Собран пол: свой образ Grafana с плагином
ClickHouse, два подготовленных источника данных, экспортёр метрик Kafka и цель
для него у Prometheus. Поверх пола стоят все три дашборда ADR 0002, они
перечислены ниже. Правил оповещения и серверного журнала `system.text_log`
пока нет.
## Состав
| Сервис | Образ | Что делает |
|---|---|---|
| `prometheus` | `prom/prometheus:v3.13.2` | опрашивает цели раз в 15 секунд, порт стенда `29090` |
| `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` |
| `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` |
Плагин ClickHouse 4.20.0 входит в образ Grafana. При сборке Grafana CLI берёт
закреплённый выпуск из официального репозитория на GitHub, а не из каталога
Grafana: прямой запрос к каталогу при проверке вернул HTTP 451. Фоновая
установка плагинов при запуске отключена.
## Кого опрашивает Prometheus
| Цель | Задание | Метка `role` | Откуда метрики |
|---|---|---|---|
| `clickhouse-01:9363` | `clickhouse` | `server` | встроенный экспортёр ноды |
| `clickhouse-02:9363` | `clickhouse` | `server` | встроенный экспортёр ноды |
| `clickhouse-keeper:9363` | `clickhouse-keeper` | `keeper` | встроенный экспортёр keeper |
| `kafka-exporter:9308` | `kafka` | `exporter` | внешний экспортёр |
Ровно эти четыре цели пересчитывает `check_prometheus_targets` в
`scripts/stand-smoke.sh` — и по значению `up`, и по списку адресов. Пятая цель
красит смоук; это и есть способ заметить, что состав пола поменяли молча.
## Источники данных Grafana
Оба источника подготовлены файлами в `infra/grafana/provisioning/datasources/`
и объявлены `editable: false`: править их через интерфейс нельзя, источник
истины — репозиторий.
- `prometheus` — основной по умолчанию, ходит на `http://prometheus:9090`.
- `clickhouse` — плагин `grafana-clickhouse-datasource`, нативный протокол,
`clickhouse-01:9000`, пользователь `grafana`, пароль из `.env`.
Источник ClickHouse смотрит на `clickhouse-01`. Системные таблицы обеих нод
панели читают через `clusterAllReplicas`, как принято в ADR 0002.
## Доступ к ClickHouse
Grafana ходит пользователем `grafana` с ролью `monitoring_reader`: читает слои,
таблицы базы `dic` и системные таблицы. `REMOTE` разрешает
`clusterAllReplicas`, `CLUSTER` — запросы `ON CLUSTER`. Решение о своей роли
записано в [ADR 0007](../../adr/0007-clickhouse-access.md). Пароль задаётся
переменной `CLICKHOUSE_GRAFANA_PASSWORD`.
## Дашборды
Каталог `infra/grafana/provisioning/dashboards/` смонтирован в контейнер и
объявлен провайдером в `provider.yml`: Grafana подхватывает оттуда файлы
дашбордов, удалять и править их через интерфейс запрещено. Новый дашборд — это
новый файл в каталоге и больше ничего: в `compose.yaml` заходить не нужно, ради
этого каталог и заведён заранее.
Файлы каталога разбирает `make config-test`, рядом с файлами дагов и по тому же
доводу ([testing.md](../testing.md)).
Порогов и цветовых вердиктов на панелях нет ни на одном дашборде: на них
смотрят глазами, красное и зелёное живёт в проверках и дагах DQ.
Собранные дашборды:
- **«Данные»** (`data.json`) — с потоком всё в порядке: свежесть, поток, брак,
сходимость Kafka с ClickHouse, отставание чтения и дневной слепок заказов.
Устройство и разбор панелей — [data.md](data.md).
- **«Кластер»** (`cluster.json`) — что с машиной: процессор, память и диски
обеих нод, части и слияния, keeper, очередь распределённых DDL. Устройство и
разбор панелей — [cluster.md](cluster.md).
- **«Запросы»** (`queries.json`) — что выполнялось и во что обошлось: тяжёлые
запросы, аппетит по посетителям, ошибки. Устройство и разбор панелей —
[queries.md](queries.md).
## Что проверено
Права роли `monitoring_reader` сверены 21 августа 2026 года по документации
ClickHouse через MCP Context7; разбор — в
[ADR 0007](../../adr/0007-clickhouse-access.md), раздел «Что проверено».
Сам состав проверен на живом стенде 21 августа 2026 года, собранном заново
после `make clean`.
- Grafana видит плагин `grafana-clickhouse-datasource` 4.20.0
(`/api/plugins?type=datasource`), оба подготовленных источника здоровы.
- Запрос источника ClickHouse к `system.one` через `clusterAllReplicas` вернул
2 — с одной ноды видны обе.
- Prometheus видит ровно четыре цели, все со значением `up = 1`; запрос
`kafka_consumergroup_lag` вернул ряды по группам `clickstream_*`.
- `make smoke` 21/21, `make check-clickhouse` 10/10, `make check-services` 7/7.
+156
View File
@@ -0,0 +1,156 @@
# Дашборд «Кластер»
`infra/grafana/provisioning/dashboards/cluster.json` — семь панелей. Вопрос у
экрана один: что с машиной? Состав панелей и довод за каждую —
[ADR 0002](../../adr/0002-monitoring-scope.md).
Свой вопрос каждая панель называет заголовком прямо на экране. Здесь — только
источники, о них и речь ниже.
| Панель | Откуда берёт |
|---|---|
| Процессор | `system.metric_log` |
| Память | `system.asynchronous_metric_log` |
| Диски | `system.metric_log` |
| Части | `system.parts` |
| Слияния | `system.part_log` |
| Keeper | встроенные метрики keeper через Prometheus |
| Распределённый DDL | `system.distributed_ddl_queue` |
Окно у дашборда — полчаса, обновление — полминуты. Пачка из
`make generate-batch` проходит за двадцать секунд, и на получасовой оси это
узкий всплеск в пару столбиков. Шире окно не берут, чтобы всплеск не выродился
в точку; уже — чтобы не промахнуться мимо него, заглянув через минуту, и чтобы
рядом осталось спокойное время: подъём читается только над полом. Кто хочет
разглядеть форму вплотную, растягивает окно мышью по самому всплеску.
Панель частей окна не знает вовсе. `system.parts` — опись живых частей, и
вопрос к ней про сейчас: колонки со временем в ней есть, но ни одна не годится
в ось. Очередь DDL окна тоже не знает, но по другой причине: она показывает
последние двадцать записей, потому что в спокойные полчаса пуста, а пустая
панель ничего бы не рассказала.
## Обе ноды на одной панели
Системная таблица у каждой ноды своя: `system.metric_log` на `clickhouse-01`
знает только про `clickhouse-01`. Запрос без обёртки показал бы половину
правды и ничем бы себя не выдал — вторая нода просто не появилась бы в легенде.
Поэтому запросы идут через `clusterAllReplicas`, а `hostName()` подписывает,
чья это строка.
Исключений два, и они разной природы. Очередь распределённых DDL лежит не у
ноды, а в keeper, и обе ноды читают из него одну и ту же очередь целиком,
вместе со строками про соседа. Обёртка здесь не добавила бы правды, а удвоила
бы каждую строку — это единственная панель дашборда без неё. Правило поэтому
звучит так: `clusterAllReplicas` нужен там, где у каждой ноды своя правда.
Второе исключение мягче. Панель слияний читает обе ноды той же обёрткой, но
складывает их в один ряд: шарды сливают симметрично, и разделение удвоило бы
легенду ради двух одинаковых кривых.
## Пол на графиках — журналы самого сервера
ClickHouse ведёт около десятка собственных журналов: `metric_log`,
`trace_log`, `text_log`, `query_log` и соседние. Все они — таблицы MergeTree:
их пишут и сливают постоянно, даже когда на стенде не происходит ничего.
Отсюда процессор и диски, которые никогда не падают в ноль.
Вычесть этот пол на процессоре и дисках нечем: их счётчики знают, сколько
работы сделал процесс, и не знают, ради какой таблицы. Своя нагрузка на этих
двух панелях читается как подъём над полом, а не как число само по себе.
У частей и слияний пол отделим: в `system.parts` и `system.part_log` у каждой
строки написано, какой таблице она принадлежит. Обе панели поэтому показывают
всё, кроме журналов сервера, — и у каждой на это своя причина.
Столбик у слияний шириной в минуту, а не в шаг панели. Слияния — события
редкие: на своём шаге столбик выродился бы в волосяную линию, и четыре события
за полчаса пришлось бы искать глазами.
Слияниям фильтр иначе не пережить по размеру: за спокойные полчаса они съели
6 492 части в системных журналах против 219 в `ods` и `stg` вместе. Оставь мы
журналы в кадре, ряды слоёв прижались бы к нулю, и панель рассказывала бы про
уборку за сервером вместо приехавших данных. Сколько эта уборка стоит, видно
этажом выше, на процессоре и дисках.
Частям фильтр нужен для другого: без него в таблице десятки строк, а значит
нужен предел — и предел рвёт пару нод пополам, оставляя таблицу с одной нодой в
кадре. С фильтром помещаются все, и предел не нужен.
## Что именно меряют процессор, память и диски
Процессор считается по времени самого сервера
(`ProfileEvent_OSCPUVirtualTimeMicroseconds`), а не по загрузке машины.
Метрики с именами `OS*` сняты с хоста целиком: обе ноды показывали бы в них
одно и то же число, и панель на два ряда потеряла бы смысл.
Память показана резидентной (`MemoryResident`), а не той, что сервер считает
своей (`MemoryTracking`). Разница не косметическая: на праздной ноде
`MemoryTracking` — 113 МиБ при 846 МиБ резидентной памяти, а ядро, решая, кого
убить за нехватку памяти, смотрит на вторую величину
([ADR 0004](../../adr/0004-resource-limits.md)). По первой нода, которую
вот-вот убьют, выглядит совершенно здоровой.
Пунктирный ряд «отведено» — предел памяти контейнера, та самая коробка из
ADR 0004. Он тоже читается живьём, метрикой `CGroupMemoryTotal`: впиши мы
4 ГиБ числом в JSON, панель молча соврала бы в тот день, когда предел поменяют.
У дисков считаются обращения к файлам — `OSReadChars` и `OSWriteChars`.
Счётчики блочного устройства (`OSReadBytes`) для той же панели не годятся:
блочное чтение на стенде почти всё время нулевое, части лежат в страничном
кэше, и слияние перечитывает их оттуда. Работа при этом идёт настоящая, и
панель по блокам показывала бы её рваным пунктиром.
## Keeper и очередь DDL — то, что живёт вне нод
Keeper — отдельный сервис, системных таблиц у него нет, и панель по нему
единственная на дашборде приходит из Prometheus.
Показывает она размер дерева. В keeper лежит журнал репликации, а в нём —
запись на каждую часть: пока поток идёт, дерево растёт, а когда части
домержились и старые записи убраны, оседает. Замер за полчаса живого потока:
9 078 узлов на подъёме, 11 512 на пике, 10 308 через десять минут после того,
как поток встал.
Очередь распределённых DDL — вторая половина того же сюжета: через keeper
проходит каждый запрос `ON CLUSTER`. Заглядывают сюда с одним вопросом: доехал
ли он до обеих нод и что ответила каждая.
## Что проверено
Дашборд проверен на живом стенде 21 августа 2026 года.
- Дашборд поднимается из каталога сам, все семь панелей отвечают через
`/api/ds/query` в окне на полчаса. Пустых нет.
- Обе ноды видны на четырёх панелях из пяти, что читают системные таблицы нод.
Пятая — слияния, там ноды сложены намеренно. Без `clusterAllReplicas`
осталась только очередь DDL, и обёртка ей действительно не нужна: запрос с
ней вернул с `clickhouse-01` и с `clickhouse-02` одно и то же число записей —
на день замера 124 и 124.
- Под нагрузкой. `make generate-batch` (50 626 событий одной пачкой), замер в
десятисекундных вёдрах: процессор поднялся с 0,04–0,18 ядра до 0,54 на одной
ноде и 0,64 на другой, запись — с 400 КиБ/с до 2,1 и 4,5 МиБ/с. Ведро тут
часть ответа: в тридцатисекундном тот же прогон даёт 0,29 и 0,21 ядра.
Следующие десять секунд обе величины уже вернулись к прежним: на таком объёме
подъём и оседание укладываются в полминуты.
- Слияния под той же нагрузкой отозвались столбиком в ряду `stg` высотой в
15 съеденных частей.
- Блочное чтение действительно молчит: за минуту вокруг вставки `OSReadBytes`
был нулевым в десяти десятисекундных окнах из четырнадцати, а обращения к
файлам шли непрерывно во всех.
- Память: около 1 ГиБ занятого против 4 ГиБ отведённого на каждой ноде.
- Панель частей показала `ods.order_rep` по 142 строки в части против 13 046 у
`ods.event_rep` — заказы приезжают дневным слепком, события потоком. Строк
вышло восемь: четыре таблицы, каждая обеими нодами. Таблиц ошибок среди них
нет, и это правда состояния — частей у них пока не появилось.
- Очередь DDL показала `CREATE MATERIALIZED VIEW ... ON CLUSTER` в состоянии
`Finished` и со временем по каждой ноде. Новые безопасные записи для показа
оставляет `make check-clickhouse`: цель создаёт и удаляет временные таблицы
`ON CLUSTER`. Канонический DDL появляется в очереди при создании мира дагом
`world_initialize`.
Подписи рядов (`${__field.labels.node}`) и правило, по которому пунктирным
становится только ряд «отведено», сверены по документации Grafana через MCP
Context7. И то и другое применяется на стороне браузера, через
`/api/ds/query` их не видно: там проверено только, что нода приезжает меткой
ряда, а метрика — его именем.
+117
View File
@@ -0,0 +1,117 @@
# Дашборд «Данные»
`infra/grafana/provisioning/dashboards/data.json` — шесть панелей на один
экран. Вопрос у экрана один: с потоком всё в порядке? Состав панелей и довод за
каждую — [ADR 0002](../../adr/0002-monitoring-scope.md).
Свой вопрос каждая панель называет заголовком прямо на экране. Здесь — только
источники, о них и речь ниже.
| Панель | Откуда берёт |
|---|---|
| Свежесть | `ods.event_dist`, `ods.order_dist` |
| Поток | `stg.hits_raw_dist`, `stg.orders_raw_dist` |
| Брак | те же и `ods.*_errors_dist` |
| Сходимость | экспортёр Kafka и `stg.hits_raw_dist` |
| Отставание | `kafka_consumergroup_lag` |
| Дневной слепок | `stg.orders_raw_dist` |
## Почему слои у панелей разные
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
длиннее: в STG данные привозит забор, а типизированную строку кладёт разбор.
Встань разбор — свежесть ODS это покажет, а свежесть STG — нет.
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
здесь про доставку. Местом диагностики доставленных версий физическая таблица
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
Поток и брак считаются по сырому слою. Он хранит каждую доставку и ничего не
схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, и
все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
сильнее, чем дольше живёт стенд.
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
отдельная куча, а часть сырых данных, которая не прошла разбор.
Окно у панели брака — окно дашборда, и это не косметика. Сырые данные живут трое
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
приехавшего и однажды перевалила бы за сотню процентов.
Панель дневного слепка, наоборот, окна не знает. Её вопрос про состояние, а не
про отрезок, и последние десять слепков она показывает всегда.
Отдельного ряда «против вчерашнего» на панели потока нет, и это решение, а не
пропуск. Реальное «вчера» на стенде чаще всего приходится на время, когда
стенда ещё не было. А сдвинуть ряд на модельные сутки значит записать в JSON
дашборда ускорение мира — ровно ту пометку про тик, которую [ADR
0002](../../adr/0002-monitoring-scope.md) в дашборде держать не велит: поменяют
ускорение, и панель молча соврёт. Сравнение остаётся глазу: модельные сутки
укладываются в минуты реального времени, и соседние дни мира помещаются в одно
окно панели сами.
## Приземлившееся считают в сыром слое
Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам
экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka`
хранилищем не работают: у второго стоит `kafka_commit_on_select`
(`sql/ddl/10-stg-tables.sql`), и запрос из панели забрал бы слепок себе,
сдвинул офсеты, а даг приёма приехал бы к пустому топику.
## Как считается сходимость
Произведённое живёт в Kafka, приземлившееся — в ClickHouse, и со своей стороны
ни одна из них не видит другую. Поэтому панель смешанная: ряд «произведено в
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
стороны ClickHouse — `count()` строк STG по тому же интервалу.
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
растягивает приращение на всё окно и на минутном интервале промахивается на
проценты: здоровый стенд выглядел бы расходящимся. Накопленный итог от начала
окна точнее обоих приростов, но отсчитывать его надо от края окна — а на
свежем стенде окно начинается раньше, чем появился экспортёр, и ряд пропадает
целиком.
Столбцы ClickHouse подписаны правым краем, а не левым. Точка Prometheus — это
прирост к моменту времени, а `count()` по интервалу — сумма, которая набирается
к его концу; подпиши мы её началом, здоровые ряды разъехались бы ровно на один
столбец. Шаг панели при этом не мельче минуты: экспортёр опрашивают раз в
пятнадцать секунд, и на более мелком шаге ряд Kafka пошёл бы ступеньками там,
где ряд ClickHouse идёт гладко.
Точка в точку ряды всё равно не совпадут, и ждать этого не надо: опрос
экспортёра не попадает на границы столбцов, поэтому на разгоне потока линия
Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в
один столбец не значит ничего.
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден сразу.
Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему другой — не
«сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него отвечает своя
панель, и модельный день она берёт из самого сообщения: в STG он приезжает полем
`snapshot_date`.
## Что проверено
Дашборд проверен на живом стенде 21 августа 2026 года, `world_live` снят с
паузы.
- Дашборд поднимается из каталога сам: файл положен, панели появились без
ручного импорта. Сохранение и удаление через API отвергнуты («Cannot save
provisioned dashboard», «provisioned dashboard cannot be deleted»).
- Все шесть панелей отвечают через `/api/ds/query` — и в окне часа по
умолчанию, и в окне на шесть часов, которое начинается раньше, чем поднялся
экспортёр. Пустых нет ни при идущем потоке, ни при остановленном.
- Брак: подсунутое в топик `hits` негодное сообщение легло в
`ods.event_errors` с классом `keyset_mismatch`; панель показала единицу и
долю 0,039 % в десятиминутном окне. Строка убрана мутациями из
`stg.hits_raw_rep` и `ods.event_errors_rep`, обе таблицы вернулись к нулю.
- Сходимость: на живом потоке за двадцать пять минут медиана расхождения между
рядами 3 %, худший столбец 11 % — на переломе потока, как и описано выше.
- Свежесть реагирует на остановленный поток и возвращается. Через пять минут
после конца модельного дня панель показала 305 с у событий и 288 с у
заказов; стоило пустить следующий день, события вернулись к секунде.
Свежесть заказов при этом растёт до конца дня и садится, когда доедет его
слепок, — так и устроен второй источник.
+76
View File
@@ -0,0 +1,76 @@
# Дашборд «Запросы»
`infra/grafana/provisioning/dashboards/queries.json` — три панели поверх одной
таблицы, `system.query_log`. Вопрос у экрана один: что выполнялось и во что это
обошлось? Состав панелей и довод за каждую —
[ADR 0002](../../adr/0002-monitoring-scope.md).
Свой вопрос каждая панель называет заголовком прямо на экране. Здесь — только
то, чего в заголовке не скажешь.
Журнал у каждой ноды свой, поэтому все три панели читают его через
`clusterAllReplicas` — то же правило, что и на [«Кластере»](cluster.md).
Окно — час, обновление — полминуты. Час взят потому, что сюда приходят с
вопросом о прошлом: «почему у меня всё стояло десять минут назад». Полминуты —
чтобы только что запущенный тяжёлый запрос не пришлось ждать: в журнал он
попадает через несколько секунд после конца.
## Первым в списке видно сам мониторинг
Grafana ходит в ClickHouse обычным пользователем, и её запросы ложатся в
журнал наравне с человеческими. На спокойном стенде верх списка тяжёлых
запросов занимает `grafana`: панель свежести с дашборда «Данные» читает по
полмиллиона строк за каждое обновление.
Так и надо: мониторинг тоже стоит денег, и цену видно там же, где и всё
остальное. Спрячь мы свои запросы фильтром — цена ушла бы с глаз, а панель «Кто
спрашивает» недосчиталась бы самого частого посетителя.
Различать посетителей дашборд умеет потому, что у Grafana своя учётка со своей
ролью ([ADR 0007](../../adr/0007-clickhouse-access.md)). Ходи она под
аналитиком, панель «Кто спрашивает» показывала бы одну строку на всех.
## Что отброшено и почему
Каждый запрос попадает в журнал дважды: строкой начала и строкой конца. В
строке начала нет ни длительности, ни памяти, ни прочитанных строк — там их
ещё неоткуда взять. Панели считают по строкам конца.
Распределённый запрос кладёт в журнал не только себя. Инициатор пишет свою
строку, а каждый шард — свою, про доставшийся ему кусок. Две панели из трёх
считают только запросы инициатора: иначе один запрос человека выглядел бы
тремя, а прочитанные строки сложились бы дважды.
Панель ошибок, наоборот, куски шардов показывает: отказ случается там, где
считали, и строка шарда — единственное место, где эта нода названа. В замере с
принудительным пределом памяти `clickhouse-01` записал свой отказ, а
`clickhouse-02` — свой, и вторая строка видна только без фильтра по
инициатору.
## Пустой пользователь
Часть строк журнала приходит без имени пользователя: так записана собственная
работа сервера — чтение топиков движком Kafka, вставки материализованных
представлений, обслуживание. Панели подписывают такие строки «фоновая работа
сервера»: пустая клетка в таблице читается как поломка, а это не она.
## Что проверено
Дашборд проверен на живом стенде 21 августа 2026 года.
- Дашборд поднимается из каталога сам, все три панели отвечают через
`/api/ds/query` в окне на час. Пустых нет.
- Тяжёлый запрос виден. `GROUP BY` по `ods.event_dist` от учётки `analyst`
приехал в панель со своими числами: 741 мс, 53,32 МиБ памяти, 701 994
прочитанные строки.
- Ошибки видны и различаются по природе: отказ по правам (`bi` без `SELECT` на
`ods`) и ошибка в тексте запроса (несуществующая колонка) легли в панель
разными строками, каждая с полным текстом.
- Строка шарда в панели ошибок настоящая. Принудительный предел памяти на
распределённом `GROUP BY` дал две записи: `clickhouse-01` с
`is_initial_query = 1` и `clickhouse-02` с нулём. Вторая пропала бы, стой на
панели тот же фильтр, что на соседних.
- Панель «Кто спрашивает» разделила шесть учёток — `grafana`, `analyst`, `etl`,
`dict`, `bi`, `default` — и фоновую работу сервера. Больше всех прочитала
`grafana`: 11,5 млн строк за час, своими же панелями.
+8 -14
View File
@@ -27,19 +27,21 @@
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` — не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
[судьба заказа](fate.md). [судьба заказа](fate.md).
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока — - **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
заказная и событийная стороны; броски независимы, пересечения выходят `ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
арифметикой, приоритет классов работает по-настоящему — пересечения выходят арифметикой, приоритет классов работает по-настоящему —
[классы расхождений и опоздание](fate.md). [классы расхождений и опоздание](fate.md).
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов - **Паспорт хранит только то, чего движение мира не меняет**: хеш байтов
каждого слепка и счётчики наблюдаемых классов — каждого слепка и счётчики наблюдаемых классов — [что хранит
[что хранит опись](inventory.md). паспорт](inventory.md).
- **Мост к склейке**: план состава владеет человеком; его непрозрачный - **Мост к склейке**: план состава владеет человеком; его непрозрачный
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным — `person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
[мост к склейке](identity.md). [мост к склейке](identity.md).
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два - **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order` `INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order`
принимает версии заказа на `ReplacingMergeTree(updated_at)` — принимает версии заказа на `ReplacingMergeTree(snapshot_date)` —
[приём из Kafka в ODS](ingestion.md). [приём из Kafka в ODS](ingestion.md).
- **Дальше заказ живёт моделью DDS** — уже по хранилищную сторону границы,
в наборе слоя: [модель заказа](../dds/order.md).
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого - **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
дня клики есть, а заказов нет, и график выручки дозаполняется по ходу дня клики есть, а заказов нет, и график выручки дозаполняется по ходу
мира — [заказы в стартовом мире](start-world.md). мира — [заказы в стартовом мире](start-world.md).
@@ -49,14 +51,6 @@
## Открытые решения ## Открытые решения
- **Имя подпотока событийной стороны** — вместе с её содержимым на этапе 6;
из мёртвых имён никто не бросает, переименование ничего не сдвигает (#72).
Заказная сторона уже названа — подпоток `ORDERS` ([судьба
заказа](fate.md)).
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
доли классов, стоимость доставки — калибровка при реализации; финальная
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
потребуют только числа, не устройство.
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) — - **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
остаётся в тумане карты #69; естественное место разговора — этап 4. остаётся в тумане карты #69; естественное место разговора — этап 4.
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к - **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
+46 -42
View File
@@ -8,19 +8,18 @@
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа, **Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
решённая при его рождении и уложенная в окно K целиком. решённая при его рождении и уложенная в окно K целиком.
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, **Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, дельта,
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и опоздание. Событийная сторона — порча событийного потока: потеря и дубль. Довод
дубль. Довод за разделение — различимость по описи: правка заказной механики за разделение — различимость по паспорту: правка заказной механики не двигает
не двигает хеши событий, правка событийной не двигает байты слепка, и по хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES` видно, какую сторону трогали. Прежние имена `DISCREPANCIES` и `LATECOMERS`
и `LATECOMERS` решения не переживают — они названы по классам витрины, а решения не переживают — они названы по классам витрины, а компонент называет
компонент называет часть мира; новые стороны занимают те же позиции. часть мира; новые стороны занимают те же позиции. Заказная сторона названа при
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2; реализации — подпоток `ORDERS` на позиции 2; событийная — `BEACONS` на позиции
событийная получит имя вместе с содержимым на этапе 6, до тех пор позиция 3 3: маячок счетчика либо не долетает, либо отправляется повторно. Отклонено:
занята мёртвым именем прежней нарезки. Отклонено: *один компонент на всю *один компонент на всю судьбу* — правка событийной механики молча меняла бы
судьбу* — правка событийной механики молча меняла бы байты слепка; байты слепка; *компонент на класс* — пять имён под ручки калибровки, которые
*компонент на класс* — пять имён под ручки калибровки, которые крутятся крутятся разом.
разом.
**Правило формы, без которого разделение не работает: броски заказной стороны **Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
@@ -56,19 +55,21 @@
часа покупки, и менти нашёл бы этот наклон первым же разрезом. часа покупки, и менти нашёл бы этот наклон первым же разрезом.
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как **Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
она делится между двумя дорогами — строки таблицы исходов. Точные числа — она делится между двумя дорогами — строки таблицы исходов. Замер на
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим. каноническом мире (28 дней, 2026-08-30, docs/architecture/testing.md, «Что
проверено»): отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12%
отменен неоплаченным; в пределах ориентира. Проверок вида «отмен от 4 до 6
процентов» не заводим.
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя **Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии версия; различает их история версий: строки STG и физические версии `ods.order`
`ods.order` до фоновых слияний, а в витринах — выручка дня, которая до фоновых слияний, а в витринах — выручка дня, которая сначала выросла, потом
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние убыла. Полное различение не обещано: слепок — состояние на границе суток, и
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у оплата с отменой в один день в сырых данных неразличимы; то же у сильно
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока опоздавших, приехавших уже терминальными. Точную форму этого урока решает этап
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда деться, кроме
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* — как остаться брошенным; *мгновенная отмена при рождении* — «дыхание» окна на
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1, отменах исчезает; *отмен нет вовсе* — страховочный срез 1, он в резерве.
он в резерве.
## Классы расхождений и опоздание ## Классы расхождений и опоздание
@@ -108,12 +109,13 @@
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где один в один — посчитал наивно, удвоил выручку. Дубль случается только там,
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у где пауза до следующего визита куки длиннее таймаута и задержки дубля вместе:
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID` иначе сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон,
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита, ради которого `VisitID` в потоке лежит. Задержка дубля —
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с секунды-минуты, короче таймаута визита, поэтому `VisitID` тот же. Полночь
покупкой, по тому же правилу, что у подтверждения с торговым хвостом. режет дубль парой — просмотр вместе с покупкой, по тому же правилу, что у
подтверждения с торговым хвостом.
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* — Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
потеря перестаёт быть видна со стороны трекера. потеря перестаёт быть видна со стороны трекера.
@@ -127,18 +129,20 @@
подделывается — строка создана, когда заказ родился, — но в слепках дней подделывается — строка создана, когда заказ родился, — но в слепках дней
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
дожил: отменённый на второй день и опоздавший на третий приедет в первом же дожил: отменённый на второй день и опоздавший на третий приедет в первом же
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка — таблица своём слепке как `cancelled` — «выгрузка догоняет жизнь». Таблица задержки
весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть «D+1/D+2» состоит из трёх строк: δ = 0/1/2 с весами 90/5/5; 1 и 2 — это D+1 и D+2
мастер-спеки. Меряется она в днях снятия слепка, а не отправки: иначе сдвиг мастер-спеки. Бросок идёт последним в подпотоке `ORDERS`; затем δ назначенного
отправки удвоился бы, и обещанные D+1/D+2 стали бы D+2/D+3. Дальше таблица не планом заказа обнуляется. Меряется задержка в днях снятия слепка, а не
идёт: заказ с δ = 6 приехал бы ровно в одном слепке, и обещание «пропущенный отправки: иначе сдвиг отправки удвоился бы, и обещанные D+1/D+2 стали бы
день ничего не ломает» на нём перестало бы быть верным; при δ ≤ 2 у всякого D+2/D+3. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно в одном
заказа слепков не меньше пяти. Легенда: заказ ушёл в ручную обработку и попал слепке, и обещание «пропущенный день ничего не ломает» на нём перестало бы
в выгрузку позже. Отклонено: *сдвиг `created_at`* — подделка аудита источника: быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
день создания строки разошёлся бы с днём покупки, чьё равенство держит Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Отклонено:
синхронная модель ([откуда берётся заказ](snapshot.md)), заказ уехал бы в *сдвиг `created_at`* — подделка аудита источника: день создания строки
чужую партицию, и «выручка дня D» перестала бы отвечать покупкам дня D — разошёлся бы с днём покупки, чьё равенство держит синхронная модель
сломалась бы та самая сверка, ради которой всё строится. ([откуда берётся заказ](snapshot.md)), заказ уехал бы в чужую партицию,
и «выручка дня D» перестала бы отвечать покупкам дня D — сломалась бы та
самая сверка, ради которой всё строится.
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет **Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
+4 -4
View File
@@ -24,10 +24,10 @@
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`; `ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
отдельные прогоны одного мира дают то же соответствие; принятый мир отдельные прогоны одного мира дают то же соответствие; принятый мир
воспроизводит эффект склейки без случайных ложных объединений — конкретные воспроизводит эффект склейки без случайных ложных объединений. Паспорт не
числа пар контрактом описи не являются. Межкогортные столкновения принимаются закрепляет конкретное число пар. Межкогортные столкновения принимаются по той же
по той же дисциплине, что у случайных `ClientID`: принимается конкретный дисциплине, что у случайных `ClientID`: принимается конкретный канонический мир
канонический мир по внешнему результату. по внешнему результату.
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* — пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
+72 -28
View File
@@ -7,10 +7,10 @@
## Проблема ## Проблема
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
отдельными сообщениями и не сообщает потребителю, где слепок закончился. Прямое отдельными сообщениями и не сообщает потребителю, где слепок закончился.
чтение Kafka Engine возвращает одну порцию. Поэтому прежняя публикация через Прямое чтение Kafka Engine возвращает одну порцию без признака полноты.
`REPLACE PARTITION snapshot_date` приравнивала дату наблюдения к отсутствующей Поэтому `snapshot_date` не может служить границей публикации: `REPLACE
транспортной границе и могла заменить день неполным набором строк. PARTITION` могла бы заменить партицию дня неполным набором строк.
Одновременно типизированный ODS не должен принимать правдоподобные значения по Одновременно типизированный ODS не должен принимать правдоподобные значения по
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки. умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
@@ -19,8 +19,8 @@
- сохранить пакетный забор как контраст потоковому приёму событий; - сохранить пакетный забор как контраст потоковому приёму событий;
- один раз принять байты в STG и независимо разложить строки на годные и брак; - один раз принять байты в STG и независимо разложить строки на годные и брак;
- хранить в ODS типизированные версии заказов, не привязывая идемпотентность к - хранить в ODS типизированные версии заказов, не публикуя слепок заменой
`snapshot_date`; партиции;
- дать следующим слоям один корректный способ прочитать текущее состояние; - дать следующим слоям один корректный способ прочитать текущее состояние;
- оставить код проверки коротким и ограничить его контрактом провода. - оставить код проверки коротким и ограничить его контрактом провода.
@@ -35,28 +35,32 @@
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт запускает его шаговый даг модельного времени, который положил слепок в топик и
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow. ждёт конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта. `_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
Запрос забора лежит в [`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql): Запрос забора лежит в
даг задаёт порядок и параметры, а преобразование остаётся в SQL своего слоя. [`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
пишет годные строки в `ods.order`, второй — брак в `ods.order_errors`. пишет годные строки в `ods.order`, второй — брак в `ods.order_errors`.
Транзакции между запросами нет. При частичном сбое Airflow повторяет весь Транзакции между запросами нет. При частичном сбое Airflow повторяет весь
`task_id`; одинаковые исходные строки и служебные метки не вычисляются заново. `task_id`; одинаковые исходные строки и служебные метки не вычисляются заново.
Запросы лежат рядом с целями: Обе вставки лежат в одном самодостаточном файле
[`order_load.sql`](../../../sql/ods/order_load.sql) и [`order_parse.sql`](../../../sql/ods/order_parse.sql). Airflow сначала
[`order_errors_load.sql`](../../../sql/ods/order_errors_load.sql). шаблонизирует файл, затем делит получившийся SQL на выражения и исполняет их по
порядку в одной задаче `parse_batch`.
Условия запросов взаимодополняющие: один общий предикат определяет брак, а Условия запросов взаимодополняющие: один общий предикат определяет годную
годная ветвь использует его буквальное отрицание. Все функции предиката строку, а ветвь брака использует его буквальное отрицание. Все функции предиката
возвращают результат без исключения, а сам предикат всегда заканчивается в возвращают результат без исключения, а сам предикат всегда заканчивается в
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для `true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
этого не нужен. Обе ветви включают один файл этого не нужен. Предикат объявлен локальным макросом Jinja внутри
[`_order_wire_contract.sql`](../../../sql/ods/_order_wire_contract.sql), поэтому `order_parse.sql` и разворачивается в оба выражения. Макрос убирает
предикат нельзя случайно исправить только в одной из них. дублирование, но не вводит отдельный фрагмент, который нельзя исполнить
самостоятельно.
## Граница строгого приёма ## Граница строгого приёма
@@ -78,6 +82,13 @@ JSON-объектом с точным набором ключей: `order_id`, `
малый контракт заказа нет причины. Граница строгости заканчивается на форме малый контракт заказа нет причины. Граница строгости заканчивается на форме
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже. провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
Контракт источника задаёт частоту: **одной дате соответствует один слепок**. Это
свойство всего слепка, поэтому предикат одной строки его не проверяет. Вторая
выгрузка того же дня даст ничью по `snapshot_date`, и выбор строки потеряет
однозначность. Модельный источник выполняет условие; в настоящей интеграции
его фиксируют в договоре с источником
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
Класс брака выбирается первым совпадением: Класс брака выбирается первым совпадением:
1. `not_an_object`; 1. `not_an_object`;
@@ -91,18 +102,35 @@ JSON-объектом с точным набором ключей: `order_id`, `
## Роль ODS ## Роль ODS
`ods.order_rep` хранит физически принятые версии в `ods.order_rep` хранит физически принятые версии в
`ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день `ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
неизменного `created_at`. `ods.order_dist` шардирует по день неизменного `created_at`. `ods.order_dist` шардирует по
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и `cityHash64(order_id)`: только так все версии заказа попадают на один шард и
`FINAL` даёт корректный результат через распределённую таблицу. `FINAL` даёт корректный результат через распределённую таблицу.
Четыре координаты отвечают на разные вопросы: Четыре координаты отвечают на разные вопросы:
- `updated_at` — какая бизнес-версия заказа новее;
- `snapshot_date` — в слепке какого модельного дня источник показал строку; - `snapshot_date` — в слепке какого модельного дня источник показал строку;
- `updated_at` — когда строку последний раз меняли в источнике;
- `_load_id` — какой запуск Airflow принял строку; - `_load_id` — какой запуск Airflow принял строку;
- `_load_ts` — когда строка приехала в хранилище. - `_load_ts` — когда строка приехала в хранилище.
Версию выбирает `snapshot_date`: выживает строка с максимальной датой слепка
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому
`snapshot_date` показывает последнее наблюдение заказа. По строкам последней
даты слепка DDS определяет окно пересборки. `_load_id` и `_load_ts` принадлежат
той физической строке, которую сохранило слияние, и описывают её доставку.
Схема версий стоит на дисциплине источника: слепок с поздней датой не
показывает состояния старше. Модельный мир её держит — состояние читается на
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
больше не показывает, и там помогает только сверка самих полей, в терминах
dbt snapshots — стратегия `check` вместо `timestamp`.
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории. диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения `ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
@@ -112,8 +140,9 @@ JSON-объектом с точным набором ключей: `order_id`, `
Это представление остаётся ответственностью ODS: оно скрывает механику чтения Это представление остаётся ответственностью ODS: оно скрывает механику чтения
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает, версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
какие сущности, связи и производные признаки ему нужны. Нужен ли `_load_id` какие сущности, связи и производные признаки ему нужны. Судьба служебных
выше ODS, решается вместе с DDS, а не здесь. колонок выше ODS решена вместе с моделью DDS: чужие ярлыки туда не переезжают
([модель заказа](../dds/order.md)).
## Одно чтение Kafka ## Одно чтение Kafka
@@ -129,7 +158,7 @@ JSON-объектом с точным набором ключей: `order_id`, `
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде. собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он Позиция мира при этом не двигается, и тот же день уедет заново — в STG он
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»). ([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
@@ -163,10 +192,10 @@ JSON-объектом с точным набором ключей: `order_id`, `
## Что проверено ## Что проверено
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94. Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение сырья на годные Опыты этого раздела прогнаны и подтвердили обещанное: разбиение строк STG на
строки и брак полное и непересекающееся; две версии одного заказа легли в одну годные строки и брак полное и непересекающееся; две версии одного заказа легли в
партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от фонового одну партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от
слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её фонового слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки `_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
опирается, — [документ хранилища](../storage.md), «Что проверено». опирается, — [документ хранилища](../storage.md), «Что проверено».
@@ -178,6 +207,21 @@ JSON-объектом с точным набором ключей: `order_id`, `
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь `20260608-0007`, который прежде приходил с отрицательным итогом, теперь
`5490.00 − 823.50 + 299.00 = 4965.50`. `5490.00 − 823.50 + 299.00 = 4965.50`.
При исполнении #124 21 августа 2026 года адресная пересборка мира прошла через
`SQLExecuteQueryOperator`: `pull_batch` и `parse_batch` завершились успешно. В
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
пользователя `etl` для забора в STG, записи годных строк и записи брака.
Дисциплина источника замерена 22 августа 2026 года при исполнении #137.
Во всех 3034 парах строк с равным `updated_at` деловые поля совпали
побайтово, а по слепкам метка ни разу не убывала.
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним слепком
заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из сырых
данных. В обеих сверках расхождений нет.
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны, #93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR
+17 -17
View File
@@ -1,30 +1,30 @@
# Что хранит опись # Что хранит паспорт мира
Резолюции развилок [«Расхождения 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` хранит
словом. Сойтись с запросом менти они могут только на днях с закрытым окном: число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных
при N сыгранных днях таких N − 7 (день d закрывается слепком d + 6, а днях закрыты N − 7 (день d закрывается слепком d + 6, а последний
последний отправленный слепок несёт день N − 2). Читатель у них придёт отправленный слепок несёт день N − 2). Их читатель — техническая
этапом 4 — проверка сверки; не окажется читателя — та же бритва режет и их. проверка `classes_vs_inventory` из DQ-звена этапа 4.
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных - **Контрольные числа идентичности не заводятся вовсе**: uniq известных
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
не выходит; генератор сторожит хеш, транспорт — счёт событий. не выходит; генератор сторожит хеш, транспорт — счёт событий.
- **Опись описывает мир, а не доставку.** Работа генератора кончается на - **Паспорт описывает мир, а не доставку.** Работа генератора кончается на
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его Kafka: дошли ли байты до `ods.order` — вопрос стенда и его проверок. Поэтому
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка счёта строк у слепка в паспорте нет; понадобится проверка приёма заказов —
приёма заказов — число заведётся вместе с ней. число заведётся вместе с ней.
+14 -11
View File
@@ -18,9 +18,10 @@
в трекере — не отдельная порода, а класс B, и делает его событийная сторона в трекере — не отдельная порода, а класс B, и делает его событийная сторона
выбрасыванием события после присвоения номера выбрасыванием события после присвоения номера
([классы расхождений](fate.md)); ([классы расхождений](fate.md));
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`, - обе проекции используют один номер заказа: `order_id` = клиентский
читаемый номер «день и порядковый номер покупки» ([спека `purchaseID`;
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все читаемый номер «день и порядковый номер покупки» описан в
[ADR 0017](../../adr/0017-order-numbering.md); нумеруются все
покупки, дошедшие до потока дня, — до всяких потерь; покупки, дошедшие до потока дня, — до всяких потерь;
- скидка заказа выводится из промокода события по таблице «код → скидка» — - скидка заказа выводится из промокода события по таблице «код → скидка» —
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9). числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
@@ -59,12 +60,14 @@
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
топиком `hits`* — убивает два режима приёма. топиком `hits`* — убивает два режима приёма.
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и **Сборка окна.** Слепок дня D берет заказы, рожденные в дни D−6…D, а несет из
собирается переигровкой этих семи дней: заказы дня — производная всей воронки них те, что уже попали в выгрузку: механика опоздания живет в
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у [судьбе заказа](fate.md). Окно собирается переигровкой этих семи дней: заказы
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние дня — производная всей воронки дня, дешёвого пути к ним нет. Цена — семь
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок; проигрышей дня (~14 с) на слепок; у начала оси окно усекается само. Отклонено:
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве. *кэш заказов на томе* — состояние между прогонами; *окно держит хранилище* —
топик перестаёт нести слепок; *K = 1* — это страховочный срез 1 мастер-спеки,
он в резерве.
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим **Отправка.** Слепок **снимается на границе суток, а отправляется следующим
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
@@ -109,5 +112,5 @@
такого значения означает ошибку арифметики источника. такого значения означает ошибку арифметики источника.
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка — Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт его
его даром, а хешу слепка в описи нужен именно названный порядок. даром, а хешу слепка в паспорте нужен именно названный порядок.
+16 -15
View File
@@ -6,13 +6,14 @@
Генератор играет два источника с разными темпами — поток Метрики и ночную Генератор играет два источника с разными темпами — поток Метрики и ночную
выгрузку магазина; разные темпы дают всё остальное. выгрузку магазина; разные темпы дают всё остальное.
`world-init` играет восемь дней одним прогоном `batch --day 0 --days 8`; Даг `world_initialize` играет восемь дней одним прогоном
слепки отправляет второй запуск — команда `snapshot` того же диапазона (два `batch --day 0 --days 8`; слепки отправляет второй запуск — команда `snapshot`
источника — два запуска, [слепок и его доставка](snapshot.md)), и со сдвигом того же диапазона (два источника — два запуска,
отправки уезжают **слепки дней 0…6**. Слепок дня 7 снят на границе суток и [слепок и его доставка](snapshot.md)). Со сдвигом отправки уезжают **слепки
уедет первым же ходом мира. Особого режима у стартового мира нет: правило дней 0…6**, затем даг `orders_ingest` принимает их в хранилище. Слепок дня 7
отправки живёт в одном месте — в проигрывателе; генератору это решение не снят на границе суток и уедет первым же ходом мира. Особого режима у стартового
стоит ничего. мира нет: правило отправки живёт в одном месте — в проигрывателе; генератору
это решение не стоит ничего.
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет; Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся. глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
@@ -24,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/).
+352 -254
View File
@@ -6,17 +6,20 @@
раздел «Что проверено» — чему в этом тексте верить и на каком основании. раздел «Что проверено» — чему в этом тексте верить и на каком основании.
**Что здесь описано и чего ещё нет.** Собран этап 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)). ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат публичным договором
Дальше по тексту устройство описано так, как оно проектируется; построенное от события `dds.event_v` над точным состоянием ODS, а следом получил две физические
заложенного отличает карта таблиц в конце. модели слоя — заказ и сессию: их собирает заменой партиций даг `dds_transform`.
Сессия среди них особая: в потоке такой сущности нет, стенд режет её из событий
сам. Дальше по тексту устройство описано так, как оно проектируется; построенное
от заложенного отличает карта таблиц в конце.
Зона ответственности у документа одна — хранилище. Генератор описан отдельно: Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
@@ -27,76 +30,95 @@ 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, и имена её показывают. [исследовании](../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` ее не показывает.
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из События, `dds.session_dist` и `dds.identity_map_dist` распределены
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё по `cityHash64(client_id)` (в ODS поле называется `ClientID`). Данные
прочее — через `GLOBAL`. Значит `dds.session` и `dds.identity_map` шардируются по одного посетителя лежат вместе — это ко-локация. Заказы DDS и их сверка
`cityHash64(ClientID)`. Объекты DDS с зерном заказа должны сохранять ко-локацию сохраняют раскладку по `cityHash64(order_id)`. Обычное соединение допускается
по `cityHash64(order_id)`; ключи остальных частей будущей модели и витрин по ключу ко-локации; для остальных соединений применяется `GLOBAL`.
появятся вместе с ними. Само совпадение ключей еще не гарантирует локального выполнения запроса
через представления — пример есть в [описании `daily_traffic`](dm.md#dmdaily_traffic_v).
Замена партиций не используется для `ods.order`: версии прошлых дней Для дневной пересборки DDS и DM результат сначала записывают
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR в промежуточную таблицу. Основная таблица в это время остается доступна
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка для чтения с прежними данными. Например, заказы записываются через
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее `dds.order_stage_dist` в `dds.order_stage_rep`, затем ее дневные разделы
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким заменяют разделы основной `dds.order_rep`.
объектом, а не переносить на ODS заранее.
У промежуточной и основной таблиц должны совпадать структура, ключи
партиции и сортировки, первичный ключ и политика хранения. Поэтому
`dds.order_stage_rep` объявлена через `AS dds.order_rep` с совпадающими
ключами. Требования сверены через Context7 с
[документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition)
для схемы на ClickHouse 26.3. DDL показан в
[`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)).
## Служебные колонки ## Служебные колонки
@@ -115,10 +137,10 @@ CSV-файла. Живёт он вне цепочки слоёв, в зоне `d
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена `_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера `Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо
либо падение приёма на первом сообщении, либо тихие нули за 1970 год. падение приёма на первом сообщении, либо тихие нули за 1970 год.
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
@@ -131,18 +153,18 @@ CSV-файла. Живёт он вне цепочки слоёв, в зоне `d
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
0010](../adr/0010-order-versions-in-ods.md)). 0010](../adr/0010-order-versions-in-ods.md)).
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition, Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство
которого он заведён: повтор доставки в сырье обязан быть виден. слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает `ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
повтор доставки. У `ods.order` версию задаёт `updated_at` источника; повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
`_load_ts` только показывает, когда конкретная строка приехала. источника; `_load_ts` только показывает, когда конкретная строка приехала.
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся. `created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
@@ -150,7 +172,7 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью. `_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
Airflow. Переделать разобранное руками можно вставкой из сырья с фильтром по Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу `_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
`_load_id`; дневные партиции ODS он не заменяет. `_load_id`; дневные партиции ODS он не заменяет.
@@ -175,14 +197,21 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет `ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное
состояние читают через него, а не повторяют `FINAL` в каждом запросе. Будущее состояние читают через него, а не повторяют `FINAL` в каждом запросе.
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том числе `dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
snake_case-имена и расшифровку кодов. числе snake_case-имена и расшифровку кодов.
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
исключение: источник объявляет `Array(Float64)`, а материализованное
представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`.
DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден
документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
с сырыми данными подтвердило: копейки всех покупок целы.
## Версии заказов ## Версии заказов
`ods.order_rep` хранит принятые версии в `ods.order_rep` хранит принятые версии в
`ReplacingMergeTree(updated_at)`: ключ сортировки — `order_id`, партиция — `ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
`toDate(created_at)`. `ods.order_dist` шардирует по `toDate(created_at)`. `ods.order_dist` шардирует по
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли `cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL` схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
@@ -192,10 +221,13 @@ snake_case-имена и расшифровку кодов.
версии, которые ещё не убрали фоновые слияния, и не является архивом истории. версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
`ods.order_v` служит поверхностью точного текущего состояния для следующих `ods.order_v` служит поверхностью точного текущего состояния для следующих
слоёв. Представление сохраняет язык источника и не решает, какой станет модель слоёв. Представление сохраняет язык источника и не решает, какой станет модель
DDS. `snapshot_date` в нём остаётся датой наблюдения строки, а не ключом DDS. `snapshot_date` в нём одновременно дата наблюдения и колонка версии. Она
публикации. Полное решение — в не доказывает полноту принятой порции, поэтому ODS не публикует партиции по
[ADR 0010](../adr/0010-order-versions-in-ods.md) и дате слепка. Модель версионного ODS записана в
[спецификации приёма заказов](orders/ingestion.md). [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).
## Часовые пояса ## Часовые пояса
@@ -205,16 +237,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` абсолютна,
@@ -244,7 +276,7 @@ UTC+4), и пересчёт идёт один раз при наполнении
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана **День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
генератором и лежит колонкой, так что суточные срезы группируются по ней и генератором и лежит колонкой, так что суточные срезы группируются по ней и
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9. расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md).
## Путь реплицированных таблиц в keeper ## Путь реплицированных таблиц в keeper
@@ -262,8 +294,8 @@ UTC+4), и пересчёт идёт один раз при наполнении
0008](../adr/0008-order-ingestion.md) и [спецификация приёма 0008](../adr/0008-order-ingestion.md) и [спецификация приёма
заказов](orders/ingestion.md). заказов](orders/ingestion.md).
Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица
таблица ошибок ODS. ошибок ODS.
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт `clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
@@ -292,15 +324,15 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
как при фоновом первая нода продолжает принимать и копит спул для соседа. как при фоновом первая нода продолжает принимать и копит спул для соседа.
Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё, Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой. компромисс полезнее показать, чем спрятать за галочкой.
Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым
запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято значением и запись-надгробие проходят молча, не оставляя строки. Свойство
осознанно — подробности в разделе «Что проверено». измерено и принято осознанно — подробности в разделе «Что проверено».
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано **Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
@@ -309,14 +341,14 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны. один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня `ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения. модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
сырому слою не относится. конвейера к сырому слою не относится.
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`). тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
Удвоение сырья проходит мимо неё только потому, что при повторном чтении Удвоение строк STG проходит мимо неё только потому, что при повторном чтении
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на `_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
отсутствии механизма. отсутствии механизма.
@@ -332,53 +364,54 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
роняет вставку и останавливает потребление до починки роняет вставку и останавливает потребление до починки
([ADR 0005](../adr/0005-event-ingestion.md)). ([ADR 0005](../adr/0005-event-ingestion.md)).
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырья Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого
срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события + слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG =
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило,
смотреть на оповещения красное в норме, учит не смотреть на оповещения ([ADR
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
опытом при исполнении #43, на управляемой пачке: отправили N сообщений — исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
опыт не становится, и почему — в [карте почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
проверок](testing.md), раздел «Интеграционная проверка постоянной целью не постоянной целью не становится». Счёт актуальных событий идёт через
становится». Счёт актуальных событий идёт через `ods.event_v`, которое скрывает `ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
`FINAL`: голый `count()` по `ReplacingMergeTree` зависит от того, сколько зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
мержей успело пройти, и спека это прямо запрещает (раздел 6). (раздел 6).
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS **Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
против [описи мира](../../data/world-inventory.json), лежащей в git против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make
(`make check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся
этом вся разница: опись описывает восемь дней стартового мира, счёт обрамлён разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами,
их датами, повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
помеха — ODS хранит всё. Обе стороны равенства зафиксированы: одна кодом хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая
генератора, другая файлом в git. У межслойной сверки такой опоры нет ни с файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
одной стороны.
## Срок жизни сырья ## Срок жизни сырых данных
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты, окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками
значение проставлено явно, чтобы поведение не зависело от умолчания версии. (`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не
зависело от умолчания версии.
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица Нарезать сырые данные по модельному дню события было бы соблазнительно — он
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
снимается целиком, только когда в нём истекли все строки, а в партиции модельного снимается целиком, только когда в нём истекли все строки, а в партиции
дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста, модельного дня лежит приехавшее в разное реальное время: мерж склеит куски
самая свежая строка удержит весь кусок, и данные переживут срок неограниченно. разного возраста, самая свежая строка удержит весь кусок, и данные переживут
срок неограниченно.
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
возраста — но не более чем на сутки, потому что партицию закрывает календарный возраста — но не более чем на сутки, потому что партицию закрывает календарный
день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не
трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику,
«уходит само» здесь обещано, а «уходит вовремя» — нет. так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые
режется своими координатами: и разбор полётов, и переобработка фильтруют по данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют
`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается. модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
@@ -388,21 +421,21 @@ D0 и к реальному календарю не привязана; паке
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
эталонные дни прямо на входе. эталонные дни прямо на входе.
В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе. В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
ноль. а не ноль.
## Таблица ошибок ## Таблица ошибок
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым `ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у текстом, метаданными доставки и классом брака. Ключи её собственные, потому что
брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у у брака нет разобранных полей: шардируется `cityHash64` сырой строки —
строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и `ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню
сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним, загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
разбираться к моменту разбирательства будет уже нечем. Снятие — целыми кусками брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
(`ttl_only_drop_parts`), как у сырья и по той же причине: строки в партиции дня нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
загрузки разного возраста не более чем на сутки, и куску незачем переживать причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
срок из-за самой свежей строки. и куску незачем переживать срок из-за самой свежей строки.
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
@@ -438,9 +471,9 @@ kafka_offset)`: смотрят такую таблицу от класса, а
административная операция, роль `etl` права на неё не получает. административная операция, роль `etl` права на неё не получает.
Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт
`Decimal(18, 2)`, `productPrice` события — целые рубли. Разрыв намеренный, на `Decimal(18, 2)`, `productPrice` события — целые рубли. Поэтому точную сумму
нём стоит урок про `Float64` ([описание заказа берут из `purchaseRevenue`, а не складывают округленные цены товаров
выгрузки](../formats/clickstream-event.md)). ([описание выгрузки](../formats/clickstream-event.md)).
Устройство обоих объектов — почему такой движок, такая форма пути, такой Устройство обоих объектов — почему такой движок, такая форма пути, такой
пользователь и такое окно — расписано в пользователь и такое окно — расписано в
@@ -448,104 +481,124 @@ kafka_offset)`: смотрят такую таблицу от класса, а
## Раскладка SQL ## Раскладка SQL
Исполняемые дагами запросы лежат по правилу Исполняемые дагами запросы лежат по правилу `sql/<слой>/<объект>_<роль>.sql`.
`sql/<слой>/<объект>_<роль>.sql`. Слой — слой цели запроса: забор заказов в Слой — слой цели запроса: забор заказов в STG живёт в
сырьё живёт в `sql/stg/orders_raw_load.sql`, две ветви разбора — в `sql/ods/`. `sql/stg/orders_raw_load.sql`, разбор — в `sql/ods/order_parse.sql`. Даг
Даг называет файлы, передаёт параметры и задаёт порядок выполнения, но не называет файлы, передаёт параметры и задаёт порядок выполнения, но не хранит
хранит текст преобразований. Общий фрагмент начинается с подчёркивания: файл текст преобразований. Два выражения разбора делят локальный макрос Jinja в том
`sql/ods/_order_wire_contract.sql` сам не исполняется, его включают обе ветви же файле; подключаемого SQL-фрагмента между ними нет.
разбора.
Airflow читает и собирает эти файлы штатным шаблонизатором при исполнении SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода
задачи. Каталог `sql/` смонтирован во все его службы только для чтения. Поэтому обработчик DAG читает список DDL-файлов, загружает содержимое каждого
обработчик дагов не зависит от наличия SQL на машине при разборе Python, а названного SQL-файла и кладёт его в сериализованный граф. При исполнении Airflow
планировщик видит те же файлы при исполнении задачи. разворачивает в этом тексте Jinja; параметры ClickHouse передаются отдельно и
не подставляются строкой. Каталог `sql/` смонтирован во все службы Airflow
только для чтения.
### DDL ### DDL
Файлы лежат в `sql/ddl/` и применяются по порядку имён. Сначала все статичные Файлы лежат в `sql/ddl/` и применяются по порядку имён: источник и цель
объекты, потом матвью — тогда к моменту создания матвью её цель уже существует. создаются до представления или матвью, которые их используют.
| Файл | Что в нём | | Файл | Что в нём |
|---|---| |---|---|
| `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` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
| `50-dds-views.sql` | публичные договоры сущностей DDS |
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
| `60-dm-views.sql` | публичные договоры витрин DM |
Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
правила порядка слоёв к ней не применяются. правила порядка слоёв к ней не применяются.
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся матвью
нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает читать
привязывают первую матвью; создай её раньше разбора — и всё, что доедет в топик, когда к нему привязывают первую матвью; создай её раньше разбора — и всё,
зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На что доедет в зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в
пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет.
он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на
отладке. обычной отладке.
Применение — двумя одноразовыми сервисами при `make up`, по образцу уже Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топики `lifecycle`: один файл — одна последовательная задача
— `hits` с двумя партициями и `orders` с одной ([ADR `SQLExecuteQueryOperator`, а выражения внутри разделяет сам оператор. Всё
0008](../adr/0008-order-ingestion.md)), — затем `clickhouse-init` дожидается его `ON CLUSTER`, кроме чтеца заказов — он объявлен только на этой ноде. Перед DDL
завершения и применяет файлы с ноды 1: всё `ON CLUSTER`, кроме чтеца заказов — даг создаёт `hits` с двумя партициями и `orders` с одной
он объявлен только на этой ноде. Порядок страхует `hits` от автосоздания с ([ADR 0008](../adr/0008-order-ingestion.md)). Этот порядок не даёт `hits`
одной партицией. Переключателей тут два, и путать их не надо: брокер автоматически создаться с одной партицией. Брокер автосоздание разрешает, а
автосоздание разрешает, а потребитель librdkafka внутри ClickHouse его не потребитель librdkafka внутри ClickHouse его не просит. Оба конца измерены;
просит — оба конца измерены, см. «Что проверено». То есть стенд держится на результаты приведены в разделе «Что проверено». Успех дага означает больше,
умолчании клиента, а урок «обе ноды читают топик» умирает тихо, поэтому топик и чем применённый DDL: оба стартовых
создаётся явно, до применения DDL. источника приняты, а позиция мира установлена последней задачей. Полный граф и
состояния отказа описаны в [спеке жизненного цикла](../specs/2026-08-21-world-lifecycle.md).
Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать Повторный `make up` поверх готового мира проходит зелёным без изменений:
готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по `world_initialize` ветвится раньше DDL. Поэтому изменённый объект обычным
таймауту бросает, а `airflow-init` ждёт только первую ноду, `superset-init` — подъёмом не применяется. В разработке меняют или пересоздают только затронутый
только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что объект, партицию либо базу слоя. Если нужен целиком канонический мир,
от них зависят долгоживущие сервисы. Что делает `--wait` с одноразовым сервисом `make rebuild-storage` запускает `world_recreate`: тот удаляет прикладные базы
без зависимых, проверено при исполнении #37 на Docker Compose 2.40.3: считает `stg`, `dm`, `dds`, `ods`, `dic`, позиции групп потребителей и прикладные топики,
его упавшим и возвращает единицу, хотя контейнер вышел с нулём. Поэтому затем вызывает `world_initialize`. Службы и их тома остаются на месте, а позиция
зависимые есть и у новой пары: `clickhouse-init` ждёт `kafka-init`, а возвращается к концу стартовых дней. Полный `make clean` для изменения DDL не
`airflow-init` — `clickhouse-init`, и цепочка упирается в долгоживущий Airflow. нужен. Когда какая область очистки уместна перед проверкой, задаёт
Побочная выгода важнее обхода `--wait`: к моменту старта Airflow DDL заведомо [карта проверок](testing.md).
применён.
Повторный `make up` поверх живого тома проходит зелёным: весь DDL идёт через
`CREATE ... IF NOT EXISTS`. Оборотная сторона — изменённый объект тем же
запуском не применяется, причём молча. Отдельного механизма для этого нет и не
нужно: правка существующего DDL случается, только пока стенд пишут, а лекарство
уже есть — `make clean && make up`. Мир регенерируется, сырьё живёт трое суток,
терять нечего.
## Карта таблиц ## Карта таблиц
Ниже — то, что закладывают этапы 2 и 3; всё перечисленное лежит в `sql/ddl/`. Ниже — объекты, уже построенные этапами 2–4; всё перечисленное лежит в
`sql/ddl/`.
| База | Объект | Что это | | База | Объект | Что это |
|---|---|---| |---|---|---|
| `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` | строки слепка, не прошедшие строгий приём |
| `dds` | `dds.event_v` | событие на языке бизнес-модели без физической копии |
| `dds` | `dds.order_rep` / `_dist` | заказ в текущем состоянии, партиция — день заказа |
| `dds` | `dds.order_stage_rep` / `_dist` | двойник заказа: в нём день собирается перед заменой партиции |
| `dds` | `dds.order_v` | публичный договор заказа |
| `dds` | `dds.session_rep` / `_dist` | визит, нарезанный слоем; партиция — день сессии |
| `dds` | `dds.session_stage_rep` / `_dist` | двойник сессии: в нём день собирается перед заменой партиции |
| `dds` | `dds.session_v` | публичный договор сессии |
| `dds` | `dds.identity_map_rep` / `_dist` | известная пара куки с пользователем; таблицы полного пересчёта |
| `dds` | `dds.identity_map_v` | точный публичный договор карты идентичностей с `FINAL` |
| `dm` | `dm.revenue_daily_rep` / `_dist` | дневная выручка по категориям |
| `dm` | `dm.revenue_daily_stage_rep` / `_dist` | таблица-двойник, донор дневных партиций выручки |
| `dm` | `dm.revenue_daily_v` | публичный договор дневной выручки |
| `dm` | `dm.purchase_vs_orders_rep` / `_dist` | сверка покупок с заказами, партиция — день заказа |
| `dm` | `dm.purchase_vs_orders_stage_rep` / `_dist` | донор дневных партиций сверки |
| `dm` | `dm.purchase_vs_orders_v` | публичный договор сверки с `NULL` для отсутствующей стороны |
| `dm` | `dm.daily_traffic_rep` / `_dist` | версии дневных счетчиков трафика |
| `dm` | `dm.daily_traffic_v` | точные текущие счетчики трафика с `FINAL` |
| `dm` | `dm.dq_summary_rep` / `_dist` | текущее качество дня по атомарным проверкам |
| `dm` | `dm.dq_summary_stage_rep` / `_dist` | общий донор дневных партиций DQ-сводки |
| `dm` | `dm.dq_summary_v` | публичная сводка качества дней |
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором | | `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
| `dic` | `dic.products` | словарь товаров поверх подложки | | `dic` | `dic.products` | словарь товаров поверх подложки |
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два Матвью разбора у заказов нет: срез STG раскладывают по этим двум целям два
`INSERT SELECT` шага `parse_batch` из файлов `sql/ods/order_load.sql` и `INSERT SELECT` одной задачи `parse_batch` из файла `sql/ods/order_parse.sql`.
`sql/ods/order_errors_load.sql`.
Слой DDS и слой DM появляются на следующих этапах; их состав задан разделом В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
7 мастер-спеки и переносится сюда по мере постройки. остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
карта пишет полный набор пар в версионную таблицу. В DM выручка и сверка
заменяют партиции изменяемых дней, а трафик пишет полную историю новой версией.
## Что проверено ## Что проверено
@@ -578,6 +631,15 @@ DDL-словарь с файловым источником внутри `user_f
разобрано, почему отказ в праве на движок называет не то право, которого не разобрано, почему отказ в праве на движок называет не то право, которого не
хватает. хватает.
**Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и
`system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку
значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT`
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. На
живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов лежали на
обоих шардах, после явного нуля и повторного разбора того же `_load_id` — ни
одного. Вершина версий уцелела: деловые поля `ods.order_v` сошлись с эталоном,
посчитанным прямо из STG.
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил, **Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3. в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3.
@@ -600,19 +662,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` через распределённую
через распределённую таблицу вернул одну строку — ту, у которой `updated_at` таблицу вернул одну строку — ту, что несла позднейшую версию, — тогда как
позже, — тогда как физический счёт показывал две. Повтор задачи с тем же физический счёт показывал две. Повтор задачи с тем же `_load_id` строку в
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок записала тот же брак
записала тот же брак второй раз, как и обещано спецификацией приёма. второй раз, как и обещано спецификацией приёма.
Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у
запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, — запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, —
@@ -675,27 +737,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
@@ -728,18 +789,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
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно <локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
@@ -748,5 +810,41 @@ Kafka с пустым значением (ноль байт) и запись-н
таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет: таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет:
служебные колонки мы заполняем выражением при любом ответе. служебные колонки мы заполняем выражением при любом ответе.
**Проверка замены партиций 26 августа 2026 года (#151).** Context7 подтвердил
договор `REPLACE PARTITION`: `ON CLUSTER` в синтаксисе есть, а источник и цель
обязаны совпадать структурой, ключом партиции, ключом сортировки, первичным
ключом и политикой хранения. На закреплённом ClickHouse 26.3 замена по локальным
таблицам с `ON CLUSTER` прошла на обеих нодах, а сборка стартового мира в
двойник разложила 1662 заказа строго по ключу: шард 1 получил только чётные
`cityHash64(order_id)`, шард 2 — только нечётные.
**Проверка карты идентичностей 26 августа 2026 года (#153).** Context7
подтвердил версию в `ReplacingMergeTree` и точное чтение через `FINAL`. Два
полных прогона оставили 3180 физических строк и 1590 пар в `_v`; все пары
подтверждены покупкой и заказом, а строк на чужом шарде нет.
Тем же днём измерены ещё три поведения ClickHouse.
- **Параметр в `ALTER … PARTITION` не подставляется.** Запрос с `{day:Date}` в
выражении партиции отвечает кодом 62, синтаксической ошибкой; литерал
проходит. Отсюда единственное место в репозитории, где значение попадает в
запрос подстановкой Jinja, а не параметром ClickHouse
([`sql/dds/order_replace.sql`](../../sql/dds/order_replace.sql)).
- **`TRUNCATE` — отдельное право, в `ALTER` оно не входит.** Роль `etl`, у
которой есть `ALTER ON dds.*`, получает на `TRUNCATE` код 497, а
`ALTER TABLE … DROP PARTITION ALL` на той же таблице проходит. Двойник
поэтому чистится снятием партиций: у операции нашлась форма, на которую право
уже выдано, и расширять доступ не понадобилось.
- **`JSONExtract` читает `Decimal` прямо из строки JSON.** Цена на проводе —
строка с двумя знаками (`"1289.99"`), и разбор в
`Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))` даёт число без
промежуточного `toDecimal64`. Проверено на живом слепке; честность разбора
сторожит инвариант бэкенда — сумма «цена × количество» совпала с `items_total`
у всех 1662 заказов.
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
ClickHouse 26.3; все суммы после ODS совпали с сырыми данными до копейки.
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие **Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
матвью разбора, закрыты опытами при исполнении #43 и переехали выше. матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
+254
View File
@@ -0,0 +1,254 @@
# Superset: витрины глазами
Документ описывает зону BI: куда Superset ходит за данными и за своими
метаданными, из чего собран стендовый дашборд, как снять свежий экспорт после
правки в интерфейсе и почему правка руками живет только до перезапуска.
Состав сюжетов и колонок витрин здесь не повторяется: он живет в
[спеке](../specs/2026-07-30-stand-v2-realism.md), раздел 7, и в
[доке слоя DM](dm.md).
## Устройство зоны
Superset читает ClickHouse пользователем `bi` **на ноде 2** — так задано
подключением `ClickHouse` (`clickhousedb://bi@clickhouse-02:8123`). Нода 2
здесь — ловушка правильных ошибок из
[спеки](../specs/2026-07-30-stand-v2-realism.md), раздел «Роли нод»: DDL идет
с ноды 1, и забытый `ON CLUSTER` виден в дашборде.
Пароля в подключении нет. Его отдает штатное хранилище паролей Superset
(`SQLALCHEMY_CUSTOM_PASSWORD_STORE` в `infra/superset/superset_config.py`),
которое читает `CLICKHOUSE_BI_PASSWORD` из окружения. Поэтому файл подключения
безопасно лежит в git, и экспорт из интерфейса не должен возвращать пароль
обратно в YAML.
Свои метаданные — подключения, датасеты, графики, дашборды, пользователи —
Superset держит в базе `superset` служебного `postgres-metadata`, рядом с базой
Airflow. В ClickHouse он не пишет ничего.
## Дерево импорта
`infra/superset/import/` — это дашборд стенда в виде файлов. Каталог
смонтирован в контейнер как `/app/import`.
| Файл | Что описывает |
|---|---|
| `metadata.yaml` | версия формата, `1.0.0`; больше в нем ничего нет |
| `databases/clickhouse.yaml` | подключение к ClickHouse: URI без пароля и постоянный uuid |
| `datasets/ClickHouse/*.yaml` | три витрины: `revenue_daily_v`, `purchase_vs_orders_v`, `daily_traffic_v` |
| `charts/*.yaml` | девять графиков: четыре крупных числа, столбики выручки и кольцо долей, сводная таблица сверки, линии трафика и доля известных |
| `dashboards/business_stories.yaml` | дашборд «Бизнес-сюжеты» и раскладка панелей |
Связаны файлы не путями, а uuid. Датасет называет `database_uuid`, график —
`dataset_uuid`, а дашборд не называет графики вовсе: единственная связь с ними
лежит в его `position`, где узел `CHART-*` несет `meta.uuid` нужного графика.
График, которого нет в `position`, импортируется молча и не появляется нигде.
Uuid у стенда розданы рукой: общее начало
`19600000-0000-4000-8000-`, дальше номер объекта в последнем блоке — `1`–`3`
у датасетов, `101`–`109` у графиков, `201` у дашборда
(`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные;
их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо
обновления первой.
Датасет несет русские имена колонок, формат чисел и метрики — а график только
ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а
не в каждом графике. Два исключения проверены на живом стенде, Superset 6.1.
Подписи оси график берет не из датасета, а из своего `y_axis_format`: без него
ось выручки уходит в «1.2M». И разрез сводной таблицы подписан именем колонки
(`mismatch_class`), хотя колонке даты в той же таблице русское имя достается.
Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит
представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и
представлением не знает, а виртуальный датасет со своим 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`:
```
superset import-directory /app/import --overwrite
```
Служба `superset-init` отрабатывает перед подъемом самого Superset, то есть при
каждом `make up`. Без `--overwrite` второй прогон падал бы на первом же
знакомом uuid; с ним объект обновляется на месте, и повторная инициализация
проходит без ошибок.
Отсюда правило зоны: **стендовый дашборд не расширяют руками в интерфейсе.**
Правка живет до следующей инициализации, а потом молча заменяется тем, что
лежит в git. Пробовать интерфейс менти никто не мешает — но все, что должно
пережить перезапуск, приезжает файлом.
Это правило относится только к стендовым объектам. Свои датасеты, графики и
дашборды менти живут в той же базе метаданных Superset, а импорт трогает
только объекты с известными ему uuid. Поэтому свои объекты переживают
`make up` и `make rebuild-storage`. Их стирает только `make clean` или
адресный сброс базы метаданных по рецепту ниже.
## Как снять свежий экспорт
Маршрут один: правишь на живом стенде, забираешь экспорт, кладешь дерево в git.
1. Собрать или поправить график и дашборд в интерфейсе — на стенде, а не в
файлах. Ключи `params` графиков велики, недокументированы и меняются от
версии к версии; неверный ключ не ошибается, а рисует пустоту. Интерфейс
здесь единственный надежный оракул.
2. Снять экспорт и достать его из контейнера:
```
docker compose exec superset superset export-dashboards -f /tmp/export.zip
docker compose cp superset:/tmp/export.zip /tmp/export.zip
unzip -d /tmp/superset-export /tmp/export.zip
```
Внутри архива — один каталог `dashboard_export_<дата>` ровно той же формы,
что и дерево импорта. Команда выгружает все дашборды сразу; на стенде он
один.
3. Вычистить экспортный сор перед коммитом:
- выбросить экспортный `databases/` — наш `clickhouse.yaml` остается как
есть, с постоянным uuid и без пароля;
- оставить в `metadata.yaml` одну строку `version: 1.0.0`;
- убрать из графиков `params.datasource`, `params.slice_id` и
`params.dashboards` — это идентификаторы строк того Superset, где
снимали, а импорт выводит связи из uuid;
- убрать `query_context` — снимок запроса оттуда же. Импорт его не
требует и обратно не заполняет: после переимпорта он остается пустым, на
отрисовку это не влияет;
- перезаписать русские строки читаемыми. Экспорт кодирует их
`\uXXXX`-последовательностями, и после каждого круга их приходится
раскодировать заново: имена графиков и подписи колонок читает менти, и в
файле, и в диффе. Годится любой повторный дамп YAML с
`allow_unicode=True`;
- вернуть постоянные uuid из схемы выше.
4. Скопировать вычищенное дерево в `infra/superset/import/` и проверить
импортом **на пустых метаданных** — иначе прогон проверит только обновление
уже стоящих объектов, а молчаливый пропуск нового спрячется за старым.
Активы Superset лежат в базе `superset` службы `postgres-metadata`, поэтому
адресный сброс — эта база:
```
docker compose stop superset
docker compose exec postgres-metadata sh -c \
'psql -U "$POSTGRES_USER" -d postgres \
-c "DROP DATABASE superset WITH (FORCE)" \
-c "CREATE DATABASE superset OWNER \"$SUPERSET_METADATA_USER\""'
docker compose run --rm superset-init
docker compose up -d superset
```
Базу приходится создавать тут же: скрипт `infra/postgres/init/` заводит ее
один раз, при первой инициализации тома Postgres. `make rebuild-storage`
для этой проверки не годится — он гоняет `world_recreate` в ClickHouse и
метаданных Superset не касается. `make clean` сбросит и их, но заодно снесет
весь стенд, включая метаданные Airflow в той же службе.
После импорта дашборд смотрят глазами: это единственное доказательство —
см. ловушку ниже.
## Ловушки
**Импорт молчит.** График, чей `dataset_uuid` не разрешился, отбрасывается без
единого слова; дашборд, у которого не сошлись ссылки на графики,
пропускается. Ошибки уходят в журнал, и `init.sh`
завершается нулем в любом случае. Поэтому импорт проверяют глазами или
запросом к API — но не тем, что команда отработала.
**Знак рубля приходит не из формата валюты.** Символ Superset берет у
`Intl.NumberFormat` по коду валюты и английской локали стенда: код `RUB`
печатается как «RUB», а знак «₽» вызов роняет, и форматирование молча
откатывается к числу без валюты. Поэтому единица стоит в названии панели —
«Выручка, ₽», — а метрика несет обычный формат `,d`.
**`label_colors` знает имя ряда, а не подпись.** Для разреза имя ряда — это
значение колонки («Бытовая техника»), для графика без разреза — имя метрики
(`known_share`), а не ее русская подпись. Цвет, закрепленный за подписью
«Доля известных», не применяется.
**`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики
берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки
исчезнет при следующем старте контейнера. Правка датасета — это правка файла.
## Что проверяет стенд
`make check-services` входит в Superset, находит подключение `ClickHouse`,
сверяет его uuid и безопасный URI и требует схему `dm` — то есть подтверждает
живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не
проверяет: их смотрят глазами после переинициализации. Полная карта целей — в
[доке проверок](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: на дашборд смотрят
глазами, красное и зеленое живет в проверках.
Поводы вернуться к разговору: плагин карты начинает отдавать кросс-фильтр; у
заказа появляется адрес доставки; витрины получают общее измерение по своей
причине, не ради дашборда.
+293 -56
View File
@@ -23,9 +23,9 @@
заметит потерянного подключения Superset: он про ClickHouse и только. заметит потерянного подключения Superset: он про ClickHouse и только.
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
против описи — вопрос к ClickHouse: строки в `ods.event` считает сам против паспорта — вопрос к ClickHouse: строки в `ods.event` считает сам сервер и
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене они
они подошли бы смоуку. подошли бы смоуку.
## Карта целей ## Карта целей
@@ -34,20 +34,21 @@
спрашивают его отдельно. спрашивают его отдельно.
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено». Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
`make config-test`, `make smoke` и `make check-services` перемерены 21 августа `make smoke` и `make check-services` перемерены 28 августа 2026 года,
2026 года, `make check-clickhouse` — 19 августа, корневой `make lint` — `make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
9 августа, `make test` — 7 августа; остальные стоят с замера 6 августа. корневой `make lint` — 3 сентября, `make test` — 30 августа; остальные стоят с
замера 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` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 17 с | | `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с | | `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против паспорта | нужен | 24 с |
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 93 с | | `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) — свежими | не нужен | 71 с | | `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [паспорт мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за **Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
разными дверями охватывают разное: корневая не видит ни одного файла разными дверями охватывают разное: корневая не видит ни одного файла
@@ -71,9 +72,24 @@
тогда окружение окупится, и условие возврата — именно это, а не «стало тогда окружение окупится, и условие возврата — именно это, а не «стало
неудобно». неудобно».
Цена самого `make up` — 8 м 18 с с нуля и 3 м 35 с на живом стенде. В цену Цена самого `make up` с пустых томов — 8 м 18 с до переезда жизненного цикла в
целей она не входит, но записана здесь по той же причине: подъём пересчитывает Airflow. В цену целей она не входит. На готовом мире подъём больше не
оба источника стартового мира и ждёт их приёма. пересчитывает данные: `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 удаляет
прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL,
заливает стартовый мир и возвращает его позицию к концу стартовых дней. Службы и
их тома она сохраняет, поэтому возврат к каноническим данным не требует
холодного запуска всего стенда.
## Правило: смоук обязан оставаться быстрым ## Правило: смоук обязан оставаться быстрым
@@ -96,20 +112,28 @@ ClickHouse отвечает сразу.
Деление целей ценно ровно до тех пор, пока оно не превратилось в «гонять Деление целей ценно ровно до тех пор, пока оно не превратилось в «гонять
всегда всё». всегда всё».
Состояние перед проверкой восстанавливается в самой узкой достаточной области:
сначала объект или партиция, затем база слоя. Если нужен целиком чистый
прикладной мир, `make rebuild-storage` пересобирает его базы, топики, позиции
потребителей и данные. `make clean` нужен, только когда меняется или сломано
постоянное состояние вне этих баз либо проверяется сам запуск с пустых томов.
Требование к обоснованию полного сброса задаёт `AGENTS.md`. Неизвестную поломку
сначала исследуют: полный сброс уничтожает её следы.
| Когда | Что гонять | | Когда | Что гонять |
|---|---| |---|---|
| Правка в работе | `make config-test` и `make smoke` | | Правка в работе | `make config-test` и `make smoke` |
| PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` | | PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` |
| Приёмка этапа | `make up` с нуля и все три цели на стенде | | Приёмка этапа | все три цели на стенде; запуск с пустых томов — только для изменений начальной сборки или постоянного состояния служб |
Правки генератора добавляют к этому `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). Дешевле хеши не сравнить: чтобы
получается мир, его надо получить. Место выбрано по той же оси «кого узнать, тот ли получается мир, его надо получить. Место выбрано по той же оси
спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
краснеет проверка там, где надо: сразу после правки генератора. Не будь её, краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
поломкой хранилища, а не забытой пересборкой. поломкой хранилища, а не забытой пересборкой.
@@ -147,10 +171,10 @@ ClickHouse отвечает сразу.
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
становится темой урока. становится темой урока.
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырья срок Довод не про цену прогона, а про данные. Вложенное остаётся: у сырых данных срок
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и их.
их. Хуже явного мусора: явный видно. Хуже явного мусора: явный видно.
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.** Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
Дешевле всего это выходит партицией — опыт играет модельный день за границей Дешевле всего это выходит партицией — опыт играет модельный день за границей
@@ -159,10 +183,10 @@ ClickHouse отвечает сразу.
же механизм, на котором стоит переобработка дня X. же механизм, на котором стоит переобработка дня X.
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех, Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
что стенд произвёл сам: подневный счёт событий против описи мира ловит и что стенд произвёл сам: подневный счёт событий против паспорта мира ловит и
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге, сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки ничего при этом не вкладывая. Это единственный постоянный сторож цепочки Kafka →
Kafka → STG → ODS, и живёт он в `make check-clickhouse`. STG → ODS, и живёт он в `make check-clickhouse`.
Правило, которое такая проверка обязана выдержать и которое стоит держать в Правило, которое такая проверка обязана выдержать и которое стоит держать в
голове для следующей: **на растущих данных утверждать можно только про голове для следующей: **на растущих данных утверждать можно только про
@@ -194,12 +218,14 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
| `make check-services` | `scripts/stand-services.sh` | | `make check-services` | `scripts/stand-services.sh` |
| `make check-clickhouse` | `scripts/check-clickhouse.sh` | | `make check-clickhouse` | `scripts/check-clickhouse.sh` |
Особняком — два сценария продолжения `make up`, оба не проверки. Особняком стоит `scripts/run-dag.sh`: это не проверка, а общий REST-переход от
`scripts/wait-for-world.sh` ждёт, пока события доедут из Kafka в ODS: `make` к Airflow. `make up` запускает через него `world_initialize`, а
`docker compose up --wait` видит конец отправки, но приём идёт асинхронно. `make rebuild-storage` — `world_recreate`; сценарий ждёт появления дага в API,
Затем `scripts/ingest-starting-orders.sh` разово запускает единственный даг а затем до минуты повторяет только стартовый ответ `Dag with dag_id not found`.
приёма заказов `orders_ingest` и ждёт его конца. Оба ждут ограниченным циклом, Прочие ответы и сетевые ошибки он не повторяет: при потерянном ответе запуск мог
а не паузой наугад. уже создаться. Сценарий печатает идентификаторы дага и прогона и возвращает
вызывающему его итог. Ожидание доставки событий и запуск `orders_ingest` теперь
видны задачами `world_initialize`, а не спрятаны в shell.
Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две
проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается. проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается.
@@ -219,56 +245,267 @@ 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.**
Пересборки эталонного мира не будет (#192), поэтому доли заказной стороны
сверены замером внутренностей генератора — окно 28 модельных дней,
канонический seed, без транспорта, — тем же приемом, каким `test_plan.py` и
`test_commerce.py` меряют мир. Команда (из `generator/`):
```bash
uv run python - <<'PY'
from collections import Counter
from clickstream_generator import day, inventory, orders, world
from clickstream_generator.seeds import CANONICAL_SEED
WINDOW = 28
class_totals: Counter[str] = Counter()
outcome_totals: Counter[int] = Counter()
delivery_totals: Counter[int] = Counter()
delay_totals: Counter[int] = Counter()
total_orders = 0
for d in range(WINDOW):
today = day.stream(CANONICAL_SEED, d)
for key, value in inventory._order_class_counts(today).items():
class_totals[key] += value
money = today.orders
total_orders += len(money)
for value in money.outcome.tolist():
outcome_totals[value] += 1
for value in money.delivery.tolist():
delivery_totals[value] += 1
for value in money.snapshot_delay.tolist():
delay_totals[value] += 1
print(total_orders, dict(class_totals), dict(outcome_totals),
dict(delivery_totals), dict(delay_totals))
PY
```
6973 заказов за 28 дней. Доли из таблицы сверки (после приоритета классов):
отменено 5,15% (ориентир мастер-спеки ~5%, в пределах; из них 3,03%
оплачен-потом-отменен и 2,12% отменен неоплаченным — веса `world.py`
95/3/2); дубль `purchase` 1,89% (ориентир ~2%, в пределах); дельта суммы
1,32% (ориентир 1–2%, в пределах); потеря `purchase` 1,78% при броске 3% —
вычеты штатные: назначенные планом покупки из броска исключены (мост
склейки), часть случаев уходит в отмену приоритетом; наблюдаемая доля
принята решением владельца (#194). Стоимость доставки — 35,5% / 49,2%
/ 15,4%, совпадает с весами `DELIVERY_KOPECKS_WEIGHTS`. Опоздание слепков
(`ORDER_SNAPSHOT_DELAY_WEIGHTS`) — доля заказов с δ>0 составила 8,39%
(ориентир мастер-спеки ~10%, в пределах). Скрипт замера в репозиторий не
входит; вывод — в отчете issue #194.
**Перезамер 30 августа 2026 года при исполнении #192.** Команды запускались
из `generator/`:
```bash
time make inventory
uv run python -m clickstream_generator batch --day 0 --days 8 \
--file /tmp/stage7-events.jsonl
for snapshot_day in 1 2 3 4 5 6 7; do
uv run python -m clickstream_generator snapshot --day "$snapshot_day" \
--file "/tmp/stage7-snapshot-${snapshot_day}.jsonl"
done
wc -c /tmp/stage7-events.jsonl /tmp/stage7-snapshot-{1,2,3,4,5,6,7}.jsonl
time make test
```
Паспорт пересобрался за 20,515 с. Файловый приемник записал 460 731 812 байт:
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0 за
176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов стало
437 против 407, и потяжелел сам мир — та же пересборка восьми дней стоила 13 с,
теперь 20,5. Эта правка добавила один тест — проигрыш дня.
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
30 секунд почти целиком уходил на запуск второго приложения. Увеличивать его
не стали: Superset не обещает постоянного времени `create_app()`.
После входа проверка делает три GET-запроса к уже работающему Superset: находит
`ClickHouse`, сверяет UUID и безопасный URI, затем требует схему `dm`. Так без
второго запуска проверяются сохраненное подключение и хранилище пароля. Форму
API сверили через Context7 и с исходным кодом Superset 6.1.0. `superset test-db`
отклонен, потому что тоже запускает приложение; прямой SQL к `dbs` не проверяет
соединение.
Проверка больше не подтверждает, что сам Superset использует Postgres для
метаданных. Короткий запрос к Postgres показывает лишь готовность базы, а REST
API - подключение к ClickHouse. Для `check-services` этого достаточно.
После перезапуска Superset три прогона прошли 7/7 за 71, 67 и 70 секунд.
`make config-test` прошел. `make smoke` дал 21/21 за 25 секунд; его код не
менялся, прежний замер был 17 секунд. Неверный UUID дал 5/1: красной стала
только проверка метаданных, пароль в вывод не попал.
**Приемка #156 27 августа 2026 года.** `make rebuild-storage` занял 7 минут.
На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался
`etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли
`make smoke` 21/21 и `make check-clickhouse` 10/10. Перед стендовыми сценариями
прошли `make config-test` и `make lint`.
Два прогона `world_next_day` не изменили слои; один ручной `etl_pipeline` без
параметров догнал оба дня. Полный прогон `world_live_day` занял 27 м 2 с и
завершился только после успешных DDS и DM.
Временная синтаксическая поломка `sql/dm/daily_traffic_rebuild.sql` дала
красную цепочку: DDS завершился успешно, а `daily_traffic`, DM, `etl_pipeline`
и `world_live_day` покраснели. После возврата исправного SQL ручной
`etl_pipeline` без параметров завершился успешно на позиции `12` и восстановил
слои.
Для проверки пакетного барьера `stg.hits_raw_mv` временно отсоединили командой
`DETACH` на обеих нодах. После `world_next_day` отставание Kafka составило
46 367 (19 128 + 27 239). Во время ожидания матвью вернули командой `ATTACH`.
Журнал готовности записал повторные опросы с отставанием 46 367, затем 27 239
и 0; после этого очередь `ods.event_dist` содержала 0 файлов на обеих нодах,
и ETL завершился успешно. Проверка `EXISTS` снова дала `1` на обеих нодах,
позиция равна `13`.
После пересборки `make check-services` дважды дал 5/1: только импорт Superset
не уложился в существующий тайм-аут 30 с. Диагностический пробник импорта с
ожиданием 90 с прошел 3/0. Финальный штатный повтор прошел 7/7 за 129 с.
**Перезамер 21 августа 2026 года при исполнении #124.** Образ Airflow собран с
`apache-airflow-providers-clickhousedb==1.0.0`,
`apache-airflow-providers-common-sql==2.0.1` и
`clickhouse-connect==1.6.0`; `pip check` не нашёл несовместимых зависимостей.
Адресная пересборка мира через `make rebuild-storage` заняла 3 м 5 с: все шесть
задач DDL, `pull_batch` и `parse_batch` завершились успешно. После неё прошли
смоук 21/21 за 17 с, проверка ClickHouse 10/10 и межсервисная проверка 7/7 за
100 с.
Первый `make up` после замены образа попал в короткий рассинхрон запуска
Airflow: API уже сообщил о здоровье и отдал `world_initialize` по GET, но POST
нового запуска ещё ответил `Dag with dag_id not found`. Ошибок импорта не было;
через несколько секунд повторный `make up` завершился успешно и не менял
готовый мир.
**Перезамер 21 августа 2026 года при исполнении #114.** После добавления **Перезамер 21 августа 2026 года при исполнении #114.** После добавления
`kafka-exporter` и источника ClickHouse смоук прошёл 21/21 за 16–17 секунд, `kafka-exporter` и источника ClickHouse смоук прошёл 21/21 за 16–17 секунд,
`check-services` — 7/7 за 93 секунды. При остановленном экспортёре смоук дал `check-services` — 7/7 за 93 секунды. При остановленном экспортёре смоук дал
19/2: назвал нездоровый сервис и отсутствие одной из четырёх целей. После 19/2: назвал нездоровый сервис и отсутствие одной из четырёх целей. После
возврата сервиса прогон снова дал 21/21. возврата сервиса прогон снова дал 21/21.
**Перезамер 20 августа 2026 года при исполнении #96.** `make up` после После переноса #119 поверх этого состава повторный `make up` сохранил готовый
`make clean` прошёл за 8 м 18 с, повтор на живом стенде — за 3 м 35 с. Служба мир, смоук прошёл 21/21, проверка ClickHouse — 10/10, межсервисная — 7/7.
семи слепков заняла около 28 секунд между концом `world-init` и своим концом.
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта **Приёмка логики #119 до переноса поверх мониторинга 21 августа 2026 года.**
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время `make rebuild-storage` через Airflow прошёл за 3 м 9 с: позиция прошла задачи
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й `-1` → `0` → `8`, топики получили новые идентификаторы, группы дочитали новые
секунде, обработчик DAG стал здоров примерно на 458-й. Числа здесь называют топики без отставания, а ODS принял 401 185 событий без брака и 2 851
цену нынешнего стенда, а не выносят вердикт о регрессии. актуальный заказ. Задача `build_generator_image` заняла по 1,5 с в двух
прогонах.
Повторный `make up` занял 2 м 41 с вместе со штатным повторным запуском служб
инициализации; `world_initialize` завершился за один цикл опроса и оставил
счётчики неизменными. Отдельно проверены красные отказы `world_initialize` при
`-1` и `world_next_day` при отсутствующей позиции, `0` и `-1`; `lifecycle`
отказано в чтении `system.users`, но разрешено чтение `system.settings`.
Итоговый холодный `make up` из пустых томов занял 9 м 39 с после 25 с на
`make clean`. Даг создал отсутствующую позицию и закончил на `8`; ODS снова
получил 401 185 событий без брака и 2 851 заказ. У `hits` два раздела, у
`orders` один, отставание обеих групп равно нулю. На тогдашнем составе стенда
прошли смоук 20/20, проверка ClickHouse 10/10 и межсервисная проверка 7/7.
Оборванную очистку проверили после записи `world_position=-1`: остановили
первую ноду ClickHouse, и `world_recreate` покраснел на удалении баз. Следующий
`make up` тоже покраснел и оставил `-1`. После возврата ноды повторный
`make rebuild-storage` восстановил мир.
**Прототип 21 августа 2026 года при исследовании #110.** Предшествующая
shell-версия узкой пересборки за 2 м 25 с пересоздала прикладные базы, отправила
401 185 событий и 6 656 версий заказов и вернула позицию с 11 на 8. Замер
оставлен как ориентир цены, но не подтверждает нынешнюю реализацию Airflow: тот
вариант не очищал Kafka и был отклонён как второй оркестратор мира.
**Перезамер 20 августа 2026 года при исполнении #96, до переезда в Airflow.**
`make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за
3 м 35 с. Отправка семи слепков заняла около 28 секунд.
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и дагов
модельного времени. Весь рост приписывать слепкам нельзя: на холодном прогоне
основное время ушло ещё до сценариев ожидания: `airflow-init` завершился
примерно на 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`.
+7 -5
View File
@@ -11,10 +11,12 @@
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки. собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и Имена и типы колонок описывают сторону источника. Обычно ODS принимает их без
нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное изменений. Исключение — `purchaseRevenue`: источник объявляет Float64, а ODS
имя». Это имя источника, приведённое к snake_case, а не имя атрибута в хранит деньги в Decimal. Точный локальный тип назван в документации
модели данных: слой DDS складывает свою модель и называет атрибуты по ней. хранилища. Имя в нашем стиле ждет в столбце «Нормализованное имя». Это имя
источника, приведенное к snake_case, а не имя атрибута в модели данных: слой
DDS складывает свою модель и называет атрибуты по ней.
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора; Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
контрактом. контрактом.
@@ -90,7 +92,7 @@
| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий | | № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 37 | `purchaseID` | `Array(String)` | `object` | `purchase_id` | номер заказа; у события purchase — один элемент | | 37 | `purchaseID` | `Array(String)` | `object` | `purchase_id` | номер заказа; у события purchase — один элемент |
| 38 | `purchaseRevenue` | `Array(Float64)` | `float64` | `purchase_revenue` | выручка заказа глазами клиента; Float64, как у Метрики — на этом держится урок о расхождениях с бэкендом | | 38 | `purchaseRevenue` | `Array(Float64)` | `float64` | `purchase_revenue` | выручка заказа глазами клиента; источник объявляет Float64, ODS хранит точный Decimal |
| 39 | `purchaseCurrency` | `Array(String)` | `object` | `purchase_currency` | валюта заказа | | 39 | `purchaseCurrency` | `Array(String)` | `object` | `purchase_currency` | валюта заказа |
| 40 | `purchaseCoupon` | `Array(String)` | `object` | `purchase_coupon` | купон заказа, если был применён | | 40 | `purchaseCoupon` | `Array(String)` | `object` | `purchase_coupon` | купон заказа, если был применён |
| 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события | | 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события |
+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 и на панели слепков,
подписали модельную дату отдельно от реального времени наблюдений;
- [ ] вы можете объяснить выбор слоев для свежести, потока и доли брака
и почему числитель и знаменатель берут за одно окно;
- [ ] после наблюдений службы продолжают работать, данные и стендовые
дашборды остались без изменений.
+159
View File
@@ -0,0 +1,159 @@
# Учебные задания
Программа состоит из двух частей: основная включает семь ступеней, которые вы
проходите по порядку, а факультативы выбираете по желанию. Причины такого
порядка и формы заданий описаны в [программе учебных лаб](../specs/2026-09-01-labs-program.md#2-устройство-программы-основная-программа-и-факультативы).
## Основная программа
| Ступень | Задание | Чему учит |
|---|---|---|
| 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`](1-1-events-enriched.md) | подготовить удобный для анализа набор полей, сохранив одну строку на событие |
| 2 | [`top_pages_daily`](1-2-top-pages-daily.md) | создавать материализованную витрину с дневной пересборкой в конвейере |
| 3 | [`session_overview`](1-3-session-overview.md) | хранить слагаемые для расчета средних и долей |
| 4 | [`dq_errors_daily`](1-4-dq-errors-daily.md) | считать брак по дню загрузки и хранить итоги дольше источника |
| 5 | [`utm_effectiveness`](1-5-utm-effectiveness.md) | читать `declared_` как предупреждение о сумме со слов клиента |
Общее устройство слоя — [справочник DM](../architecture/dm.md). Он отвечает на
вопросы «почему так», задание отвечает на вопрос «что сделать».
## Как принято на ступени 1
**Читать — через `_v`.** Физические `_rep` и `_dist` служат загрузке и
обслуживанию, а публичный договор DDS — `dds.event_v` или `dds.session_v` —
остается прежним даже при изменении физических объектов. Чтение DDS
напрямую делает задания независимыми: соседняя витрина может быть еще не
построена.
Исключение — задание 4: у таблиц брака публичного договора нет.
**Писать — в `_dist`.** Так строки распределяются по ключу шардирования. При
записи в локальную таблицу распределение зависит от узла, выполнившего
запрос, и при следующем запуске может измениться. Подробности — раздел
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
**Форма — по зерну.** На стенде агрегат с новым зерном принято хранить
готовым: потребитель читает его много раз без повторного расчета по
источнику. Создайте `dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` /
`_stage_dist` и публичное `dm.<имя>_v` с явным списком колонок. При прежнем
зерне достаточно представления: таблица дублировала бы данные. Доводы —
раздел «Таблица или представление» [справочника
DM](../architecture/dm.md).
**Ключ шардирования — ваше решение.** Объясните, почему выбранный ключ не
дает существенного перекоса и почему более сложный ничего не добавит.
Объявите донор через `AS <цель>_rep` с ключом цели: иначе замена партиции
потеряет строку на одном шарде и удвоит на другом. Детали — раздел
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
Пример обоснования — `dm.revenue_daily_dist` в
[`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
**Где лежит код.** DDL заданий — в `sql/ddl/70-labs.sql`.
При создании мира он применяется после стендовых файлов.
В уже работающем стенде сохранение файла и повторный `make up` новый DDL
не применят — выполните его сами, как описано ниже. В
`sql/dm/<витрина>_scope.sql` определяются дни прогона, в `_rebuild.sql` они
собираются в донор, в `_replace.sql` заменяется партиция. Задачу сборки
добавьте в [`dags/dm_transform.py`](../../dags/dm_transform.py): без нее
конвейер не обновит витрину. Представлению достаточно `CREATE VIEW`.
**Образец — витрина `revenue_daily`.** У нее есть пять объектов, донор,
сборка, замена дневных партиций и задача в конвейере. Используйте ее как
образец всего порядка пересборки. Состав колонок берите из своего ТЗ, иначе
получите показатели другой витрины.
## Как применить свой DDL
В 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` он не пишет: это
инструмент лабы, а не история качества стенда.
Для каждой витрины проверка независимо рассчитывает ожидаемый результат
прямо по ее источнику. У четырех заданий источник
эталона тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания
4 это таблицы брака ODS, потому что в DDS нет сущности брака. Обе стороны
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число
строк, где стороны разошлись или одной стороны нет. Ноль — зеленый. Полное
соединение сохраняет ключи обеих сторон: при внутреннем пропавший день или
ключ не попал бы в результат, и потеря осталась бы незамеченной.
Раздел «Как проверить себя» каждого задания по витрине называет деловой ключ и
сравниваемые величины. Тот же прием в стендовом исполнении — [даг
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
Запросы проверок лежат в `sql/dq/`. Откройте их после собственного решения:
они заново рассчитывают показатели витрины, поэтому содержат ответ на
существенную часть задания. Сравните свой способ расчета с готовым.
## Факультативы
Эти задания вы берете по желанию. Полный список сюжетов — в разделе
[«Факультативы — пять разделов»](../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. Поломки стенда и инструкции по восстановлению
по итогам расследований; весь раздел — повышенная сложность.
## Уборка
После опытов удаляйте свои временные объекты. Если удалили учебную витрину,
ее задача в `homework_check` должна снова завершиться ошибкой: отсутствие
витрины тоже проверяется. Подробнее — в
[справочнике проверок](../architecture/testing.md).
+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; оба клиента возвращают столько же строк,
сколько исходная витрина;
- [ ] после опыта вы удалили учебное представление с обеих нод.
@@ -126,6 +126,10 @@ parseDateTime64InJodaSyntaxOrNull(
использовать и для типизированной строки, и для маршрутизации ошибки; нулевая использовать и для типизированной строки, и для маршрутизации ошибки; нулевая
дата не нужна. дата не нужна.
> Актуальный стенд: версию `ods.order` задаёт `snapshot_date`
> ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Следующий
> абзац фиксирует исходный вывод исследования и не описывает текущий контракт.
`updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse `updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse
явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной
версией версией
@@ -0,0 +1,235 @@
# Соединение двух Distributed-таблиц на стенде
Дата исследования: 2026-08-22. Тикет #129, хвост раздела 11 мастер-спеки.
Учебный результат: менти видит на своём кластере, где именно выполняется
JOIN двух Distributed-таблиц, чем GLOBAL отличается от обычного соединения и
почему «правильный» ключ шардирования в DDL ещё не гарантирует ко-локацию
данных.
Проверено двумя дорогами: читающими запросами на живом стенде
(ClickHouse 26.3.17.56, 2 шарда × 1 реплика; запросы помечены
`log_comment = 'r129-*'`, разбор — по `system.query_log` обеих нод) и по
документации ClickHouse через MCP Context7 (страницы «JOIN Clause →
Distributed JOIN», «Distributed table engine», «IN operators → distributed
subqueries», настройки `distributed_product_mode`,
`prefer_global_in_and_join`, `parallel_distributed_insert_select`).
## Короткий вывод
- Обычный JOIN и `IN` с Distributed-таблицей справа режутся настройкой
`distributed_product_mode = 'deny'` — это заводское значение, стенд его не
менял. Ошибка: `DISTRIBUTED_IN_JOIN_SUBQUERY_DENIED`.
- `GLOBAL JOIN` собирает правую часть один раз на ноде-инициаторе, кладёт во
временную таблицу `_data_…` и рассылает её на все шарды; каждый шард
соединяет свой локальный кусок левой таблицы с этой копией. Это видно в
журнале запросов второй ноды дословно.
- Режим `local` переписывает правую Distributed-таблицу на локальную. Ответ
верен только при настоящей ко-локации по ключу соединения; иначе он молча
меньше правильного — ошибки не будет.
- Если левая часть — подзапрос, а не голая таблица, ClickHouse 26.3 вообще не
раздаёт соединение по шардам: обе стороны стягиваются на инициатор и JOIN
выполняется там один раз. `GLOBAL` и `distributed_product_mode` при этом ни
на что не влияют.
- Ловушка `FULL OUTER`: при голой левой Distributed-таблице непарная строка
правой части попадает в ответ по разу с каждого шарда — на нашем кластере
дважды. С подзапросами с обеих сторон ответ верный.
- Находка стенда: ко-локация заказов по `cityHash64(order_id)` сломана — 1189
из 1662 заказов лежат на обоих шардах одинаковыми строками. Виновата
настройка `parallel_distributed_insert_select = 2` (заводское значение
в 26.3): `INSERT … SELECT` из Distributed в Distributed выполнился локально
на каждом шарде, и строки заказов унаследовали раскладку сырья — по хешу
сырой строки. События целы: все 401 185 строк лежат по своему хешу
`cityHash64(ClientID)`.
## Как выполняется соединение: три наблюдаемых плана
### Обычный JOIN голых Distributed-таблиц — запрещён
```sql
SELECT count() FROM ods.order_dist AS a
INNER JOIN ods.order_dist AS b ON a.order_id = b.order_id
```
Обе версии планировщика (`enable_analyzer` 0 и 1) отвечают:
```text
Code: 288. DB::Exception: Double-distributed IN/JOIN subqueries is denied
(distributed_product_mode = 'deny'). You may rewrite query to use local
tables in subqueries, or use GLOBAL keyword, or set distributed_product_mode
to suitable value. (DISTRIBUTED_IN_JOIN_SUBQUERY_DENIED)
```
Причина по документации: запрос переписывается для каждого шарда, и правая
Distributed-таблица внутри такой переписки означала бы «каждый шард ещё раз
спрашивает весь кластер» — N шардов породили бы N×N подзапросов. Режим `deny`
запрещает это по умолчанию. Тот же запрет действует на `IN
(SELECT … FROM …_dist)` — поэтому в исследовательских запросах ниже стоит
`GLOBAL IN`.
### GLOBAL JOIN — правая часть собирается один раз и рассылается
```sql
SELECT count() FROM ods.order_dist AS a
GLOBAL INNER JOIN ods.order_dist AS b ON a.order_id = b.order_id
```
Журнал запросов второй ноды показывает оба шага (log_comment
`r129-bare-global`):
```text
SELECT order_id FROM ods.order_rep -- шард отдаёт свой кусок правой части
SELECT count() FROM ods.order_rep
GLOBAL ALL INNER JOIN `_data_14241958685358625318_…` ON … -- шард соединяет локальную левую с разосланной копией
```
Инициатор один раз прочёл правую часть со всех шардов, собрал временную
таблицу в памяти, разослал её и слил частичные результаты. Цена GLOBAL —
память инициатора и рассылка по сети, поэтому он для малой правой части;
заказы (2851 строка) малы.
### Подзапрос слева выключает раздачу по шардам
```sql
SELECT count()
FROM (SELECT arrayJoin(purchaseID) AS pid FROM ods.event_dist
WHERE notEmpty(purchaseID)) AS e
INNER JOIN ods.order_dist AS o ON e.pid = o.order_id
```
Этот запрос прошёл без ошибки даже при `deny`, и с `GLOBAL`, и во всех
режимах `distributed_product_mode` — план один и тот же: вторая нода получила
два независимых чтения (`SELECT … FROM ods.event_rep`,
`SELECT order_id FROM ods.order_rep`), а соединение выполнил инициатор,
прочитав целиком обе стороны (404 036 строк). Раздачи соединения по шардам
здесь нет вовсе, поэтому нет и «двойной распределённости», которую режим
`deny` запрещает.
## distributed_product_mode на живых данных
Самосоединение заказов, счёт пар строк. Правильные ответы посчитаны отдельно
как суммы квадратов кратностей: по `order_id` — 5229, по `user_id` — 6087.
| Ключ | GLOBAL | `local` | `allow` |
|---|---|---|---|
| `order_id` (заявленный ключ шардирования) | 5229 | **2851** | — |
| `user_id` (ко-локации нет и не заявлено) | 6087 | **3281** | 6087 |
`local` переписывает правую часть на локальную таблицу — в журнале второй
ноды виден дословный `… FROM ods.order_rep INNER JOIN ods.order_rep …`. Ответ
совпадает с суммой пошардовых соединений и верен только тогда, когда обе
строки каждой пары лежат на одном шарде. По `user_id` это ожидаемо не так. По
`order_id` ответ тоже неверен — потому что настоящая раскладка заказов ключу
не соответствует (см. ниже). `allow` разрешает наивный план «каждый шард
спрашивает весь кластер»: ответ верный, чтений — квадрат от числа шардов.
Документация даёт четыре режима (`deny`, `local`, `global`, `allow`) и
отдельную настройку `prefer_global_in_and_join`, которая молча превращает
обычные IN/JOIN в GLOBAL. Стенд ничего из этого не меняет: явный `GLOBAL` в
тексте запроса честнее незаметной настройки — читатель видит цену в самом
запросе.
## Ловушка FULL OUTER на кластере
Строка правой части, не нашедшая пары, добавляется в ответ каждым шардом:
```sql
SELECT countIf(b_flag = 1) FROM ods.order_dist AS a
GLOBAL FULL OUTER JOIN
(SELECT 'zzz-no-such-order' AS oid, 1 AS b_flag) AS b ON a.order_id = b.oid
-- ответ: 2 (одна фиктивная строка вернулась дважды — по разу с каждого шарда)
```
Так ведут себя обе версии планировщика. Механика прямая: каждый шард получил
копию правой части, у каждого она не нашла пары в его локальном куске, и
каждый честно отдал её как непарную. Стоит обернуть левую часть в подзапрос —
соединение выполняется один раз на инициаторе, и ответ становится верным:
```sql
… FROM (SELECT order_id FROM ods.order_dist) AS a GLOBAL FULL OUTER JOIN …
-- ответ: 1
```
## Находка: ко-локация заказов сломана
DDL обещает раскладку по `cityHash64(order_id)`, но на живых данных половина
строк лежит не на своём шарде:
| Шард | на своём шарде | не на своём |
|---|---|---|
| 1 | 696 | 725 |
| 2 | 750 | 680 |
1189 из 1662 заказов присутствуют на обоих шардах одинаковыми строками —
одна и та же версия, один и тот же `_load_id`. Пути реплик в keeper у шардов
разные, репликация ни при чём.
Причина — `parallel_distributed_insert_select = 2`, заводское значение
в 26.3. По документации: если выполняется `INSERT INTO distributed_a
SELECT … FROM distributed_b` и обе таблицы на одном кластере, запрос
выполняется локально на каждом шарде. Ровно это делает разбор заказов
(`sql/ods/order_parse.sql`: `INSERT INTO ods.order_dist … FROM
stg.orders_raw_dist`): каждый шард разобрал свой кусок сырья и записал его в
свою локальную таблицу, минуя ключ шардирования цели. Строки заказов
унаследовали раскладку STG — хеш сырой строки, то есть случайную относительно
`order_id`. Заказ, попавший в несколько дневных слепков, приезжает несколькими
разными сырыми строками и оседает на обоих шардах; ReplacingMergeTree
схлопывает дубли только внутри шарда.
Следствия уже наблюдаемы: `count()` по `ods.order_dist` даёт 2851 при 1662
заказах, а точное чтение работает только через `GROUP BY order_id` /
`argMax` — пошардовый `FINAL` через дубль на соседнем шарде не переступит.
События этой болезни не имеют: их пишет матвью приёма от Kafka-таблицы, не
из Distributed, и раскладка по `cityHash64(ClientID)` точна на всех 401 185
строках.
Починка — отдельный тикет, не этого исследования: разбору заказов нужно
явное `parallel_distributed_insert_select = 0` (строки поедут через ключ
шардирования цели), плюс пересборка уже разложенного. Тот же капкан ждёт
любую будущую сборку DDS вида `INSERT INTO …_dist SELECT … FROM …_dist`, у
которой ключ цели отличается от ключа источника.
## Выводы для identity_map (спека §5)
Сборка карты — соединение покупок из событий с заказами по
`purchaseID = order_id`. Ключ соединения не ключ ко-локации событий, поэтому
по политике раздела 6 спеки соединение обязано быть GLOBAL — и эмпирика это
подтверждает: обычный JOIN голых таблиц запрещён, `local` дал бы молча
неверный ответ. GLOBAL здесь дёшев: правая часть — заказы, их тысячи.
На практике левая часть сборки — подзапрос (события фильтруются по
`notEmpty(purchaseID)` и разворачиваются `arrayJoin`), а значит на 26.3
соединение выполнится один раз на инициаторе. `GLOBAL` в тексте при этом не
лишний: он выражает политику спеки, ничего не стоит и защищает запрос от
смены плана при другой форме запроса или версии сервера.
Запись результата в `dds.identity_map` (шардирование `cityHash64(client_id)`)
пойдёт тем же `INSERT … SELECT`. Источник — результат соединения, а не голая
Distributed-таблица, поэтому капкан `parallel_distributed_insert_select` на
эту вставку не распространяется; но проверку раскладки после первой сборки
стоит включить в приёмку — теперь ясно, что заявленный ключ и настоящая
раскладка умеют расходиться.
## Выводы для purchase_vs_orders_v (спека §7)
`FULL OUTER GLOBAL JOIN` для сверки корректен при одном условии: обе стороны —
подзапросы, и соединение выполняется один раз на инициаторе. Витрина это
условие выполняет естественно: покупки нужно развернуть из массива и
агрегировать (класс `duplicate_event` иначе не посчитать), заказы — привести
к последней версии. С голой Distributed-таблицей слева витрина удваивала бы
каждый заказ без события — весь класс `lost_event` — по числу шардов.
Прогон сверочной формы на живых данных (обе стороны — подзапросы с
`GROUP BY`): 1662 совпадения, 255 покупок без заказа (ждут слепка,
`awaiting_order`), 0 заказов без покупки, без задвоений. Обе величины
согласуются с прямыми счётами — форма пригодна как каркас витрины.
## Чем проверялось
Все запросы — читающие, от служебного пользователя внутри контейнеров
(`docker compose exec clickhouse-01 … clickhouse-client`), следы — в
`system.query_log` обеих нод по `log_comment` с префиксом `r129-`. Документные
утверждения (механика GLOBAL, режимы `distributed_product_mode`, поведение
`parallel_distributed_insert_select`) сверены с документацией ClickHouse через
MCP Context7; поведение FULL OUTER на кластере в документации не описано —
здесь источник только эмпирика.
@@ -0,0 +1,169 @@
# Проталкивание условий сквозь представления над FINAL
Дата исследования: 2026-08-22. Тикет #128, карта #127.
Вопрос: пробрасывает ли ClickHouse условия запроса внутрь обычного
представления над таблицей с `FINAL` (`ods.event_v`) и внутрь вложенного
представления над ним (будущее `dds.event_v`) — или разворачивает
представление целиком и фильтрует потом.
## Короткий вывод
- Пробрасывает. Обычное представление — это подзапрос в `FROM`, и условие
внешнего запроса доезжает сквозь него до партиций и первичного ключа
таблицы. Проверено планом и счётчиком прочитанных строк.
- Второй уровень вложенности ничего не ломает: сквозь подзапрос над
`ods.event_v` с переименованными колонками условие доезжает так же —
срезание партиций совпадает с прямым запросом к `ods.event_dist FINAL`
один в один.
- Условие доезжает даже сквозь вычисленную колонку: фильтр по
`toStartOfWeek(EventDate)` анализируется индексом MinMax.
- Единственное, что `FINAL` отнимает, — автоматический перенос условия в
`PREWHERE`: настройка `optimize_move_to_prewhere_if_final` по умолчанию
выключена. Срезание по индексам при этом работает полностью; теряется
только оптимизация порядка чтения колонок внутри уже выбранных гранул.
- Для контракта `dds.event_v` это значит: цепочка обычных представлений
ODS → DDS годится, представление до фильтра не материализуется.
## Как проверяли
Живой стенд, ClickHouse 26.3.17.56, `enable_analyzer = 1` (умолчание).
Только читающие запросы с ноды 1: `EXPLAIN indexes = 1`,
`EXPLAIN actions = 1`, `SELECT` со сверкой по `system.query_log`.
Вложенное представление не создавали — состояние стенда не менялось;
второй уровень эмулирован эквивалентным подзапросом.
Устройство пары: `ods.event_rep` — `ReplicatedReplacingMergeTree(_load_ts)`,
`PARTITION BY EventDate`, `ORDER BY (CounterID, EventDate,
intHash32(ClientID), WatchID)`; на момент опыта 16 активных кусков в
8 партициях, 24 гранулы. `ods.event_v` — `SELECT * FROM ods.event_dist FINAL`.
## Опыт 1. Фильтр по ключу партиции сквозь представление
```sql
EXPLAIN indexes = 1
SELECT count() FROM ods.event_v WHERE EventDate = '2026-06-01';
```
План читает `ods.event_rep` с условием `EventDate in [20605, 20605]` во всех
трёх индексах — MinMax, Partition, PrimaryKey: `Parts: 2/16`,
`Granules: 3/24`. Прямой запрос `SELECT count() FROM ods.event_dist FINAL
WHERE EventDate = '2026-06-01'` даёт те же цифры. Представление ничего не
прячет от анализа индексов.
## Опыт 2. Второй уровень: подзапрос над представлением
Будущее `dds.event_v` — представление над `ods.event_v` с переименованием в
язык бизнес-модели. Эмуляция:
```sql
EXPLAIN indexes = 1
SELECT count()
FROM (
SELECT WatchID AS watch_id, EventDate AS event_date,
lower(EventType) AS event_type
FROM ods.event_v
) WHERE event_date = '2026-06-01';
```
Условие по переименованной колонке доехало до таблицы: `Parts: 2/16`,
`Granules: 3/24` — цифры совпали с опытом 1. Два уровня разворачивания и
переименование колонок проталкиванию не мешают.
Отдельно проверена вычисленная колонка:
```sql
SELECT count()
FROM (SELECT toStartOfWeek(EventDate) AS event_week FROM ods.event_v)
WHERE event_week = '2026-06-01';
```
В плане условие `toStartOfWeek(EventDate) in [20605, 20605]` стоит в индексе
MinMax и срезает `Parts: 0/16` — ноль корректен: при умолчании функции ни
одна неделя не начинается в этот понедельник. То есть даже фильтр по
выражению от ключевой колонки анализируется индексом, пока функция ему
понятна.
## Опыт 3. Фильтр по ключу сортировки
```sql
EXPLAIN indexes = 1
SELECT count() FROM ods.event_v WHERE CounterID = 42150607;
```
Условие `CounterID in [...]` дошло до PrimaryKey (`Search Algorithm: binary
search`). Гранулы не срезались (`24/24`) — но это свойство данных, а не
представления: счётчик один, он есть в каждой грануле.
## Опыт 4. PREWHERE и FINAL
По колонке вне ключа (`EventType = 'pageview'`, `EXPLAIN actions = 1`,
запрос с широким списком колонок):
- `ods.event_rep` без `FINAL` — условие уехало в `Prewhere filter`;
- `ods.event_rep FINAL` и `ods.event_v` — условие осталось обычным `Filter`
над шагом чтения.
Причина — настройка `optimize_move_to_prewhere_if_final`, на стенде `0`
(умолчание). Это цена самого `FINAL`, а не представления: прямой запрос с
`FINAL` ведёт себя так же.
## Опыт 5. Счётчик прочитанных строк
План — обещание; исполнение сверено по `system.query_log`
(`read_rows` начального запроса, сумма по кластеру):
| Запрос | read_rows |
|---|---|
| `ods.event_v` без фильтра | 401 185 |
| `ods.event_v WHERE EventDate = '2026-06-01'` | 50 626 |
| подзапрос над `ods.event_v`, фильтр по `event_date` | 50 626 |
Фильтр сквозь представление читает только партиции своего дня, вложенность
не добавляет ни строки. 401 185 — все физические строки обеих нод.
## Что говорит документация
Сверено через MCP Context7 22 августа 2026 года, библиотека
`/websites/clickhouse`.
- Обычное представление не хранит данных: при обращении его запрос
исполняется как подзапрос в `FROM` — `SELECT a, b, c FROM view`
эквивалентен `SELECT a, b, c FROM (SELECT ...)`
(`sql-reference/statements/create/view`).
- Проталкивание предикатов в подзапросы — штатная работа оптимизатора;
анализатор доводит фильтры до хранилища, включая срезание партиций и
первичного ключа (changelog 2026, настройки семейства
`allow_push_predicate_*`).
- `optimize_move_to_prewhere_if_final` управляет автоматическим `PREWHERE`
для запросов с `FINAL` (`settings/session-settings`); на стенде она в
умолчании `0`, что и видно в опыте 4.
- `do_not_merge_across_partitions_select_final = 1` заставляет `FINAL`
обрабатывать партиции независимо; с 2026 года значение `1` включает режим
безусловно, а автоматику решает отдельная настройка
`enable_automatic_decision_for_merging_across_partitions_for_final`
(changelog 2026). К `ods.event` режим применим: партиция — `EventDate`,
версии одного события всегда лежат в одном дне. На стенде настройка `0`,
и трогать её не понадобилось — вопрос тикета решился без неё.
- `FINAL` поверх подзапроса не работает — модификатор обязан стоять у самой
таблицы (`guides/.../analyzer`, «Fixing Invalid FINAL Usage»). Наша
цепочка это соблюдает: `FINAL` живёт в один слой, внутри `ods.event_v`.
## Вывод для контракта dds.event_v
Сомнение владельца — «представление материализуется до фильтра» — не
подтвердилось. Условия по ключу партиции и ключу сортировки доезжают до
индексов сквозь два уровня обычных представлений, переименование колонок и
анализируемые выражения; исполняется это, а не только планируется.
Цепочка обычных представлений ODS → DDS → DM годится как форма контракта.
Две оговорки в контракт:
1. `FINAL` внутри `ods.event_v` отключает автоматический `PREWHERE` — на
объёмах стенда это незаметно, но слабый фильтр по неключевой колонке
при широком `SELECT` читает больше, чем без `FINAL`. Это свойство
`FINAL`, представления его не усугубляют.
2. Индекс срезает только то, что умеет анализировать: фильтр по колонке,
которую `dds.event_v` вычислит нетривиальным выражением, до индекса
может не доехать. Ключевые для срезов колонки — прежде всего
`EventDate` — стоит отдавать в DDS переименованием, а не пересчётом.
@@ -0,0 +1,132 @@
# Права DEFINER у представления над Distributed
Дата исследования: 2026-08-25. Тикет #150. ClickHouse 26.3.17.56.
## Вопрос
Почему `dds.event_v` с `SQL SECURITY DEFINER`, созданное пользователем
`lifecycle`, всё равно читает удалённый `ods.event_rep` как `bi`? Можно ли
оставить `bi` только с правом на публичное представление DDS, не заводя отдельный
маршрут или пользователя и не копируя данные?
## Короткий вывод
Простого поддерживаемого способа в ClickHouse 26.3 нет. `DEFINER` меняет
пользователя, чьи права проверяет локальный запрос представления, но сохраняет
в запросе имя его первоначального инициатора — `initial_user`. Движок
`Distributed` передаёт это имя на шарды, а межсерверный секрет делает его
пользователем удалённой сессии. Поэтому на координаторе источники читает
`lifecycle`, а на соседнем шарде физическую таблицу читает `bi`.
Это не заявленная семантика `SQL SECURITY DEFINER`: документация обещает, что
право на источник требуется определившему представление пользователю, а
вызывающему достаточно `SELECT` на представление. Поведение следует из
сочетания двух реализаций и выглядит как недокументированное ограничение или
дефект распределённого исполнения.
## Где меняется пользователь
`dds.event_v` разворачивается в запрос к `ods.event_v`, затем к
`ods.event_dist FINAL`. Для внутреннего запроса представления ClickHouse
создаёт новый контекст в
[`getSQLSecurityOverriddenContext`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/StorageInMemoryMetadata.cpp#L135-L175):
1. копирует исходный `ClientInfo` целиком, включая `initial_user = bi`;
2. затем отдельно вызывает `setUser` для определившего представление
пользователя `lifecycle`.
В контексте остаются два имени с разными задачами: `lifecycle` отвечает за
локальную проверку прав, а `bi` остаётся первоначальным пользователем запроса.
Второй `SQL SECURITY DEFINER` на `ods.event_v` повторяет те же два шага и
поэтому ничего не меняет.
## Что происходит на границе шарда
`Distributed` копирует `ClientInfo` из контекста и отправляет его на удалённую
ноду без замены `initial_user`
([`RemoteQueryExecutor.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/QueryPipeline/RemoteQueryExecutor.cpp#L415-L444)).
При межсерверном секрете имя входит в подписываемые данные запроса
([`Connection.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Client/Connection.cpp#L968-L994)).
Принимающая нода проверяет подпись и открывает сессию с
`AlwaysAllowCredentials{client_info.initial_user}`
([`TCPHandler.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Server/TCPHandler.cpp#L2303-L2356)).
Секрет здесь выполняет штатную функцию. Это не случайный побочный эффект.
Документация `Distributed` прямо говорит: если задан `<secret>`, `initial_user`
становится текущим пользователем удалённого запроса
([описание кластера](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/docs/en/engines/table-engines/special/distributed.md#L105-L118)).
Так права, роли и ограничения исходного пользователя одинаково действуют на
всех шардах: координатор подтверждает личность пользователя, но не превращает
его запрос в запрос своей служебной учётки. Протокол не передаёт отдельно
эффективного пользователя `DEFINER`, поэтому на стенде эта защита и
останавливает `bi` у `ods.event_rep`.
## Рассмотренные варианты
### Настройка или другая форма представления
В 26.3 нет настройки, которая подменяет `initial_user` на определившего
представление пользователя только для чтения `Distributed`.
`push_external_roles_in_interserver_queries` пересылает роли именно
`initial_user`, не роли `DEFINER`
([исходник отправки](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/QueryPipeline/RemoteQueryExecutor.cpp#L425-L444)).
`distributed_ddl_use_initial_user_and_roles` относится только к заданиям
`ON CLUSTER`. На `SELECT` настройка не действует
([объявление настройки](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Core/ServerSettings.cpp#L1002-L1006)).
### Пользователь в существующем описании кластера
С межсерверным секретом ClickHouse выбирает специальную межсерверную
аутентификацию и не использует пароль из описания реплики
([`Connection::sendHello`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Client/Connection.cpp#L452-L476)).
Чтобы удалённый запрос всегда шёл как `lifecycle`, пришлось бы убрать секрет и
передавать его пароль. Тогда между нодами перестанет передаваться исходный
пользователь. Одна модель доступа сменится другой, менее прозрачной.
### `remote()` с явными реквизитами
Для `remote()` ClickHouse умеет заменить `initial_user` пользователем из
адреса
([`executeQuery.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Interpreters/ClusterProxy/executeQuery.cpp#L96-L120)).
Но представлению понадобятся адреса и реквизиты удалённого пользователя — в
тексте DDL либо в отдельной настройке. Это и есть отдельный маршрут доступа;
он сложнее нынешнего кластера и требует хранить ещё один секрет.
### Промежуточное локальное представление на шарде
Можно направить `Distributed` на локальное представление с `DEFINER`, но
удалённому пользователю всё равно нужен `SELECT` на это представление. Значит
появятся дополнительный объект и дополнительный грант, а `bi` увидит
поверхность одного шарда. Физическая таблица останется закрытой, но простота и
контракт «только публичная поверхность DDS» исчезнут.
### Ослабление проверки
- `SQL SECURITY NONE` снимает проверку источника, но устарело и позволяет
создателю такого представления исполнять произвольный запрос; ClickHouse
требует для этого отдельное право
([документация](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/docs/en/sql-reference/statements/create/view.md#L115-L136)).
- Право `bi` на физическую ODS-таблицу решает ошибку без новой механики, но
ослабляет исходную границу.
- Физическая или материализованная копия в DDS сохраняет границу, но добавляет
хранение и жизненный цикл данных ради переименования колонок.
## Решение для стенда
25 августа 2026 года выбрана простота: роль `bi_reader` читает ODS, DDS и DM
целиком. Потребительские запросы продолжают обращаться к `_v`. Этот договор
держат документация и ревью; границы прав задают гранты.
Отдельный межсерверный маршрут, служебная учётка и физическая поверхность DDS
отклонены: каждый вариант добавляет механику только ради защиты, которой стенд
не учит. Готовой настройки, которая сохранила бы и узкую границу, и простое
устройство, в закреплённой версии нет.
## Граница уверенности
Механизм подтверждён исходным кодом точной версии 26.3.17.56. Простую настройку
искали в настройках сервера и в путях формирования удалённого запроса этой же
версии. Уверенность в обоих выводах высокая. Официального
текста, который называет сочетание `DEFINER + Distributed + secret`
поддерживаемым ограничением или зарегистрированным дефектом, не найдено;
классификация как дефекта остаётся выводом из расхождения документации и кода.
+279
View File
@@ -0,0 +1,279 @@
# Инвентаризация сюжетов лаб по слоям стенда
Это материал для спеки программы лаб (#202): полный список сюжетов без отбора. Спека решит, что оставить и в каком порядке поставить. Здесь для каждого участка стенда записано, чему на нем научится менти. Тикет — #201, карта — #182.
Каждый сюжет описан четырьмя строками: **урок**, **материал**, **форма** и **проверка**. Возможны три формы:
- *построить свое по ТЗ* — как в пяти домашних заданиях DM: рядом есть образец, но нет готового ответа;
- *вычитание* — удалить SQL готовой трансформации и написать его заново; даги остаются без изменений (уточнение владельца в комментарии к #148);
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а менти ищет причину и объясняет ее.
Существующие шесть заданий из `docs/labs/` входят в список как готовое ядро слоя DM. Идеи из тикетов #143, #145, #148, #149, #186, #187, #188 разнесены по подходящим участкам. В конце отдельно проверяется гипотеза «вычитание + DQ» для каждого потока нижних слоев.
## STG — прием и сырье
**1. Push против pull: два способа принять топик.**
Урок: способ приема зависит от источника. Поток постоянно читает подписанная матвью, а слепок забирает отдельный запрос по команде.
Материал: оба чтеца в `sql/ddl/10-stg-tables.sql` (у `orders_raw_kafka` нет ни матвью, ни `ON CLUSTER`, зато есть `kafka_commit_on_select`), матвью приема `sql/ddl/40-stg-views.sql`, забор `sql/stg/orders_raw_load.sql`, `dags/orders_ingest.py`; сравнение цены и наблюдаемости — заготовка спеки (`docs/specs/2026-07-30-stand-v2-realism.md`, раздел 12).
Форма: расследование двух живых путей на одном стенде.
Проверка: офсеты групп `clickstream_hits` и `clickstream_orders`, счет строк сырья по `_load_id`.
**2. Кто читал топик и куда легли данные.**
Урок: нода, которая читает топик, может меняться от прогона к прогону. Раскладка данных не меняется: ее определяет ключ шардирования — свойство самих данных.
Материал: колонка `consumer_host` в `stg.hits_raw_dist`; `docs/architecture/storage.md`, «Раскладка по шардам».
Форма: расследование данных.
Проверка: пересчет ключа шардирования по `hostName()` — строк на чужом шарде нет.
**3. Вторая consumer group — дубли.**
Урок: на кластер нужна одна группа потребителей. Если завести вторую, каждая нода получит полную копию топика.
Материал: `kafka_group_name` в `sql/ddl/10-stg-tables.sql`; сцена названа спекой (раздел 12).
Форма: расследование-опыт «сломай — увидь — верни» на переигранном дне.
Проверка: счет событий против описи мира (`make check-clickhouse`); уборка — снятием партиции `_load_ts` (правило из `docs/architecture/testing.md`).
**4. Дырка в офсетах.**
Урок: сырье может сохранить не все сообщения, которые пришли из источника. Пустое сообщение или зазор после пересоздания матвью видны только по разрыву в `kafka_offset`, поэтому пропажу не нужно искать глазами.
Материал: `docs/architecture/storage.md`, «Что проверено» (опыты 5 и 7 августа); колонка `kafka_offset` в сырье.
Форма: расследование.
Проверка: непрерывность офсетов внутри партиции.
**5. Две оси времени.**
Урок: переобработка данных и очистка диска живут по разным часам. Сырье разбивается на части и удаляется по `_load_ts`, а модельный день появляется только в ODS. Поэтому TTL по модельной дате работал бы неверно.
Материал: `sql/ddl/10-stg-tables.sql` (TTL трое суток, `ttl_only_drop_parts`), `docs/architecture/storage.md`, «Срок жизни сырья».
Форма: расследование-вопрос (почему нельзя нарезать сырье по `EventDate`).
Проверка: ответ сверяется с доводом доки; счетного инварианта нет.
## ODS — типизация и брак
**6. Предикат и его буквальное отрицание.**
Урок: годные строки и брак должны делить поток без пропусков и пересечений. NULL в предикате незаметно теряет строку, а исключение роняет вставку и останавливает чтение топика.
Материал: `sql/ddl/30-ods-views.sql` (пара матвью событий), `sql/ods/order_parse.sql` (макрос `order_wire_contract`), ADR 0005.
Форма: расследование — подложить в топик по строке каждого класса брака и найти их в `ods.event_errors_dist` / `ods.order_errors_dist`.
Проверка: «сырье среза = годные + брак» на управляемой пачке; строки опыта убрать за собой.
**7. Три времени и версия-наблюдение.**
Урок: у строки заказа есть четыре временные координаты. Версию сущности задает дата слепка источника, а не время загрузки. `FINAL` живет ровно в одном месте — за представлением `_v`.
Материал: `sql/ddl/20-ods-tables.sql`, `sql/ddl/30-ods-views.sql`, ADR 0010 и 0014, `docs/architecture/orders/ingestion.md` («Роль ODS»).
Форма: расследование — физические версии `ods.order_dist` против `ods.order_v`.
Проверка: эталон `argMax` по `updated_at` прямо из сырья (прием #165).
**8. Часовой пояс как способ смотреть на время.**
Урок: у ночных событий `toDate(UTCEventTime)` может не совпадать с `EventDate`. Это нормальное свойство данных. День нужно считать по готовой колонке и явно называть часовой пояс, в котором его рассматривают.
Материал: `docs/architecture/storage.md`, «Часовые пояса»; ADR 0015.
Форма: расследование данных.
Проверка: менти сам находит ночные события и объясняет расхождение.
**9. Сырое против разобранного: `ecommerce`.**
Урок: типизированный разбор JSON может молча подменить ошибку значением. Например, `qty` со значением «oops» превратится в 0. Поэтому правильность разбора нужно подтверждать инвариантом источника.
Материал: колонка `ecommerce` рядом с массивами `purchase_*` / `product_*` в `ods.event`; разбор `items` в `sql/dds/order_rebuild.sql`; спека, раздел 1.2 («кандидат будущей лабы»).
Форма: построить свое по ТЗ — разобрать `ecommerce` запросом и сверить с готовыми массивами того же события.
Проверка: построчная сверка с колонками-массивами.
## DDS — модель
**10. Лаба сессий: своя нарезка против эталона.**
Урок: сессия — расчетная сущность, границы которой определяет логика в хранилище. Самостоятельная нарезка может разойтись с эталоном. Если расхождение невозможно, задание сводится к `GROUP BY` по готовому полю.
Материал: `docs/architecture/dds/session.md`, `sql/dds/session_rebuild.sql` (два окна, полный порядок `(event_time, event_id)`, детерминированный чекан), эталон `VisitID` в `ods.event_v`.
Форма: построить свое — менти собирает нарезку сам до знакомства со стендовым кодом; либо вычитание `session_rebuild.sql` (раздел о гипотезе ниже).
Проверка: `dq_check` / `sessions_vs_reference` (зерно кука × день против `uniqExact(VisitID)`); затем сравнение со стендовой нарезкой по `session_id`.
**11. Поштучный разбор расхождения нарезки.**
Урок: счетная сверка показывает, сколько сессий разошлось. Чтобы понять, где именно отличаются границы, нужно вручную соединить свою сессию с эталонной по времени.
Материал: `sql/dq/sessions_vs_reference.sql`, `docs/architecture/dds/session.md`, «Сверка с эталоном» («поштучное сопоставление границ — материал лабы»).
Форма: расследование.
Проверка: найденная кука, день и пауза, на которой решения разошлись.
**12. Склейка идентичностей во времени (#145).**
Урок: текущее знание о пользователе и знание на выбранную дату дают разные отчеты. Если использовать сведения из будущего, исторический график известной аудитории изменится.
Материал: `dds.identity_map_v` (пары без временной колонки — решение `docs/architecture/dds/identity.md`), момент связи выводится как минимум времени `purchase`-события, соединенного с заказом.
Форма: построить свое (запрос, без изменения стенда).
Проверка: два способа на одном наборе данных; расхождение объясняется письменно.
**13. Справочник регионов (#143).**
Урок: справочник содержит данные, которыми источник владеет вне хранилища. Если собрать словарь руками, можно разобраться в механике, которую готовый каталог стенда позволяет только использовать.
Материал: образец `sql/ddl/05-dic-catalog.sql` и ADR 0012; зацепка в контракте — `region_city_id` / `region_country_id` отдаются как есть (`dds.event_v`).
Форма: построить свое по образцу (свой CSV, подложка, словарь, `dictGet` в отчете).
Проверка: обогащенный отчет; неатомарность обновления словаря на двух нодах — дополнительное наблюдение (спека, раздел 12).
**14. Инвариант позиций: недостающая проверка.**
Урок: равенство «цена × количество = items_total» проверяет, не исказил ли молчаливый разбор данные. Сейчас это равенство проверяют вручную, хотя ему нужно постоянное место в контуре DQ.
Материал: комментарий в `sql/dds/order_rebuild.sql` («постоянное место такой проверки — контур DQ этапа 4; пока она делается руками»), образцы `sql/dq/*_vs_dds.sql`, `dags/dq_check.py`.
Форма: построить свое — новая проверка по образцу существующих пяти.
Проверка: проверка обязана уметь краснеть — испортить позицию руками и увидеть красное (`docs/architecture/testing.md`).
**15. Сверка слоя с источником по дню.**
Урок: трансформация выполняет заданное правило, но сама не замечает, что часть данных не приехала. Это обнаруживает отдельная сверка слоя с источником.
Материал: `docs/architecture/etl.md`, «Чего конвейер о себе не знает» (три слепых пятна и лекарство: счет заказов и поздний `updated_at` с обеих сторон).
Форма: построить свое — вторая самодельная DQ-проверка.
Проверка: снести часть дня в ODS-окне — проверка краснеет, конвейер сам не замечал.
## DM — витрины
**16. Пять домашек DM (готовое ядро).**
Урок: готовые ТЗ уже содержат пять отдельных уроков (`docs/labs/README.md`): `events_enriched_v` показывает, что слой задает контракт, а не копирует данные; `top_pages_daily` меняет зерно, материализует витрину и включает ее в конвейер; `session_overview` учит хранить слагаемые вместо средних и не складывать уникальные значения, даже если на текущих данных результат совпадает; `dq_errors_daily` показывает, что у брака есть собственный день и срок жизни, а витрина может пережить источник; `utm_effectiveness` объясняет, зачем в имени нужно предупреждение `declared_`.
Материал: пять готовых ТЗ в `docs/labs/README.md`.
Форма: построить свое по ТЗ.
Проверка: `dags/homework_check.py` + `sql/dq/*_vs_dds.sql` (полное внешнее соединение с независимым ответом).
**17. Свой дашборд в Superset (готовая необязательная лаба).**
Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются.
Материал: `docs/labs/optional/superset-dashboard.md`, `docs/architecture/superset.md`.\
Форма: построить свое.
Проверка: глазами — счетного инварианта у раскладки панелей нет.
**18. Инкремент против пересборки (#149, повышенная сложность).**
Урок: чтобы обновлять меньше данных, приходится хранить историю изменений. Полная перезагрузка эту историю стирает.
Материал: `sql/dm/daily_traffic_rebuild.sql` (полный пересчет), `docs/architecture/dm.md`, «Ритм пересборки» и отклоненный вариант; момент `first_matched_at` — из сюжета 12.
Форма: построить свое (инкрементальная альтернатива рядом со стендовой).
Проверка: бесплатный контроль — результат обязан совпасть с эталонной витриной `dm.daily_traffic_v`.
**19. Вычитание стендовых витрин.**
Урок: при сверке двух источников нужен `GLOBAL FULL OUTER JOIN` с подзапросами с обеих сторон, `join_use_nulls` и явный приоритет классов. Значение с отсутствующей стороны должно остаться `NULL`, а не превратиться в ноль.
Материал: `sql/dm/purchase_vs_orders_rebuild.sql` (и соседние `revenue_daily_*`, `daily_traffic_*`), `docs/architecture/dm.md`.
Форма: вычитание — с оговоркой: запросы `sql/dq/*_vs_dds.sql` лежат открыто и почти дословно повторяют рецепт витрины. Предупреждение из `docs/labs/README.md` («открыв их до того, как задание сделано, ты обменяешь урок на готовый SQL») здесь особенно важно.
Проверка: `dq_check`, группа `marts_vs_dds` + `classes_vs_inventory`.
## BI — Superset (сюжеты сверх готовой лабы)
**20. Ловушка ноды 2.**
Урок: забытый `ON CLUSTER` и представление над локальной таблицей могут проявиться только в дашборде. Superset намеренно читает ноду 2, а DDL запускается с ноды 1.
Материал: `docs/architecture/superset.md`, «Устройство зоны»; спека, «Роли нод».
Форма: расследование-опыт (создать объект без `ON CLUSTER`, открыть дашборд).
Проверка: своя же находка и починка.
## Поперек слоев
### Airflow-переобработка по дневным партициям
**21. Переиграть день X.**
Урок: объем повторного прогона определяется состоянием хранилища. После падения конвейеру не нужно помнить параметры прошлого запуска: он ищет первую несобранную дату, а не последнюю собранную.
Материал: `sql/dds/order_scope.sql` и `session_scope.sql` (окно + хвост от первой дыры, `GLOBAL NOT IN`), `dags/dds_transform.py` (день — отдельная размноженная задача замены), `docs/architecture/etl.md`.
Форма: расследование-опыт — снести партицию дня в `dds.session_rep`, запустить `etl_pipeline` без параметров, увидеть, как объем сам нашел дыру.
Проверка: `dq_check` зеленый, день вернулся тем же (детерминированный чекан).
**22. Атомарность — пошардовая.**
Урок: `REPLACE PARTITION` заменяет данные в локальных таблицах по одному шарду. Если одна из двух замен завершится ошибкой, половина дня останется старой. Сам конвейер этого не заметит, поэтому нужна отдельная сверка.
Материал: `sql/dds/order_replace.sql` (комментарий о границе обещания), `docs/architecture/etl.md`, «Чего конвейер о себе не знает»; правило донора — `sql/ddl/45-dds-tables.sql`.
Форма: расследование (в паре с сюжетом 15).
Проверка: `purchase_vs_orders_vs_dds` или самодельная сверка по дню.
### Сверка purchase vs orders (классы A–D)
**23. Воронка подтверждений и недосчет трекера (#186, класс B).**
Урок: со стороны трекера потерянное событие можно обнаружить только по разрыву воронки между `/confirmation` и `purchase`. Отсюда правило: поведение считаем по трекеру, деньги — по бэкенду.
Материал: `lost_event` в `dm.purchase_vs_orders_v`, события дня в `dds.event_v`; механика — `docs/architecture/orders/fate.md` («потеря точечная»).
Форма: расследование.
Проверка: оцененный недосчет сходится с долей `lost_event` закрытых дней.
**24. Бизнес-дубль и наивная выручка (#187, класс D).**
Урок: движок удаляет технические дубли, а бизнес-дубли приходится определять самим — по `purchaseID`, а не по совпадению строк. `ReplacingMergeTree` не удалит дубль с новым `WatchID`, и это правильное поведение.
Материал: `duplicate_event`, свертка по `purchase_id[1]` в `sql/dm/purchase_vs_orders_rebuild.sql`; `fate.md` («дубль сюжетный»).
Форма: расследование — наивный `SUM` против дедуплицированного.
Проверка: разница равна вкладу дублей; сверка с `dm.revenue_daily_v`.
**25. Дельта суммы объясняется сравнением позиций (класс C).**
Урок: `amount_delta` обозначает конкретное расхождение, а не случайный шум. Его можно до копейки объяснить вычеркнутой позицией, если разобрать вложенный JSON и применить `ARRAY JOIN`. Ради этого навыка позиции и разбираются.
Материал: `amount_delta` в сверке, массивы `product_*` события против `item_*` заказа; `fate.md` («дельта — вычеркнутая позиция»).
Форма: расследование.
Проверка: найденная позиция и ее полная стоимость равны дельте.
**26. Две дороги отмены и история версий (класс A).**
Урок: текущее состояние заказа не сохраняет его историю. Различить случаи «оплачен и отменен» и «отменен неоплаченным» можно только по следам: физическим версиям ODS до слияния и сырью STG, пока оно хранится трое суток.
Материал: `fate.md`, «Что из двух дорог видно»; `ods.order_dist`, `stg.orders_raw_dist`; выручка дня, которая выросла и убыла.
Форма: расследование с ограничением по времени — часть случаев неразличима принципиально, и признать это тоже урок.
Проверка: списки заказов по дорогам, сверенные с историей версий.
### Опоздания и закрытие дня
**27. Опоздавшие заказы и закрытое окно (#188).**
Урок: отчет за день зависит от момента расчета. День можно считать закрытым, когда источник уже не способен его изменить. Поэтому опись хранит счетчики классов только для закрытых дней.
Материал: `awaiting_order` в `dm.purchase_vs_orders_v`, задержка слепков D+1/D+2 (`fate.md`, «Опоздание»), `data/world-inventory.json` (счетчики `orders` только у дней с закрытым окном), возврат `awaiting_order`-дней в объем — `sql/dm/purchase_vs_orders_scope.sql`.
Форма: расследование по ходу проживаемых дней.
Проверка: вчерашний отчет, не сошедшийся утром, сходится завтра; разность слепков называет опоздавших.
**28. Дыхание выручки.**
Урок: значение выручки за день меняется, пока день внутри окна изменяемости, а после выхода из окна фиксируется. Эти изменения видны как повторные пересборки партиции.
Материал: `dm.revenue_daily_v` по ходу дней, окно из ярлыка `snapshot_date` (`sql/dm/revenue_daily_scope.sql`), K = 7 — константа мира.
Форма: расследование-наблюдение (снять один день несколько раз).
Проверка: за окном значение больше не меняется.
### Мониторинг и runbook
В репозитории пока нет runbook для случаев «keeper упал / DDL повис». Спека оставляет его в хвосте этапа 9 вместе с `system.text_log` (ADR 0002), потому что такой документ должен опираться на реальный опыт. Поэтому менти может написать runbook сам: по шагам сломать сценарий, записать наблюдения и оставить стенду документ, которого сейчас не хватает.
**29. Keeper — общая память кластера.**
Урок: без keeper останавливаются DDL и репликация. На дашборде это бывает видно раньше, чем в ошибках запросов. Читаемый путь в keeper помогает понять, какие данные он хранит и за что отвечает.
Материал: панель keeper и очередь DDL в `docs/architecture/monitoring/cluster.md`, `system.zookeeper` (читаемый путь — `docs/architecture/storage.md`, «Путь реплицированных таблиц»), `make smoke` / `make check-clickhouse`.
Форма: расследование-опыт «посмотрели — сломали — увидели — вернули» (остановить keeper), итог — свой runbook.
Проверка: возврат к зеленым целям `make`; шаги записаны.
**30. Повисший DDL.**
Урок: каждый запрос `ON CLUSTER` проходит через keeper. Очередь распределенных DDL показывает, дошел ли запрос до обеих нод и что ответила каждая из них.
Материал: `system.distributed_ddl_queue`, панель «Распределенный DDL» (`cluster.md`: единственная панель без `clusterAllReplicas` — и почему).
Форма: расследование-опыт (DDL при недоступной второй ноде, таймаут `ON CLUSTER`).
Проверка: запись в очереди со статусом по нодам; починка доводит до `Finished`.
**31. Мониторинг сам стоит денег.**
Урок: запросы Grafana попадают в `system.query_log` вместе с запросами людей. Самый частый клиент хранилища — сам мониторинг. Если отфильтровать его запросы, стоимость наблюдаемости станет невидимой.
Материал: `docs/architecture/monitoring/queries.md` (панель «Кто спрашивает», строки начала и конца, куски шардов в панели ошибок).
Форма: расследование.
Проверка: собственное объяснение верха списка тяжелых запросов.
**32. Свежесть второго источника — пила, а не линия.**
Урок: возраст заказов растет в течение дня и резко уменьшается после нового слепка. Так устроен источник; это не признак деградации. Для панелей свежести, потока и брака выбираются разные слои данных. Если выбрать неверный слой, панель даст правдоподобный, но ложный ответ.
Материал: `docs/architecture/monitoring/data.md` (почему свежесть — ODS, поток и брак — сырье, окно доли брака).
Форма: расследование.
Проверка: предсказать форму графика до взгляда на него.
### За границей лаб — опорные точки лекций
Спека (раздел 12) оставляет несколько сюжетов для лекций и не включает их в лабораторные работы: `Sign` и CollapsingMergeTree (почему на стенде `sum(Sign)` равен `count()`, а в бою нет), «как выбирают топологии в бою», идентичность «как в бою» (`setUserID`, вероятностная склейка, identity graph), анти-паттерны ключа шардирования. Последний сюжет уже входит в домашние задания DM как требование защитить выбранный ключ (`docs/labs/README.md`).
## Гипотеза «вычитание + DQ» для нижних слоев
В #148 предложено удалить SQL трансформаций потока и написать его заново. Владелец уточнил, что даги менять не нужно: это несущая инфраструктура. Обратную связь дают инварианты, которые не зависят от реализации слоя. Ниже каждый поток проверяется отдельно: что можно отдать на переписывание, что должно остаться, чем проверять результат и где заканчивается эталон.
На всю таблицу влияют два общих факта:
- `homework_check` покрывает только пять витрин DM. `dq_check` содержит пять проверок, но внешний эталон есть лишь у двух: `sessions_vs_reference` использует чужой чекан `VisitID` из `ods.event_v`, а `classes_vs_inventory` — опись мира. Три проверки `*_vs_dds` пересчитывают витрину из DDS по тому же рецепту. Они находят ошибку в сборке витрины, но не ошибку в DDS, потому что обе стороны читают один слой.
- Все сверки читают таблицы `_dist`, поэтому их агрегаты не зависят от раскладки (спека, раздел 6). Если строка попала не на свой шард, счетные проверки останутся зелеными. Это ловит только пересчет ключа шардирования по `hostName()` (прием из `docs/architecture/storage.md`, замер #137). Такой пересчет нужно явно включать в ТЗ каждого вычитания.
### По потокам
**События STG (прием).** На переписывание этот участок не отдается. Здесь почти нет трансформации: `stg.hits_raw_mv` перекладывает колонки (`sql/ddl/40-stg-views.sql`). При этом пересоздание матвью на работающем стенде незаметно пропускает ближайшие сообщения (замер в `storage.md`, «Что проверено»). Чтец, таблицы сырья и матвью остаются несущей стеной. Урок этого участка раскрывают расследования из сюжетов 1–5.
**События STG → ODS (разбор).** Вся логика — в DDL, в паре матвью `sql/ddl/30-ods-views.sql`; отдельных файлов событий в `sql/ods/` нет. Удаление и пересоздание этих матвью остановит прием и снова создаст зазор с пропущенными сообщениями. Поэтому обычное вычитание здесь не подходит. Рабочая форма — **«собери рядом»**: выполнить пакетный `INSERT SELECT` из сырья в собственную таблицу по срезу `_load_ts`, а затем построчно сверить результат с нетронутым `ods.event_v`. Эталоном служат стендовый ODS и подневный счет против описи (`make check-clickhouse`). Однако опись проверяет только количество и только для покрытых ею дней. Содержимое неопорных колонок остается без независимой проверки: неверно разобранные UTM или браузер нигде не покраснеют, потому что витрины сверяются с DDS, а DDS читает ODS. Срок работы тоже ограничен: сырье хранится трое суток.
**Заказы STG (забор, `sql/stg/orders_raw_load.sql`).** Формально трансформация вынесена в отдельный файл, но ее учебный смысл сводится к перекладке колонок, а цена ошибки велика. Офсеты коммитятся при чтении, поэтому неверный повтор унесет слепок до следующего переигрыша дня через пульт. Этот участок остается стеной. Эталон описи — хеш байтов слепка — проверяет топик, но не сам забор.
**Заказы STG → ODS (`sql/ods/order_parse.sql`).** Вычитание возможно с оговоркой. Это пакетный шаг, идемпотентный по `_load_id`: повтор не меняет `ods.order_v` («Что проверено» в `orders/ingestion.md`). Брак обрабатывается отдельной целью, а слепок можно вернуть переигрышем дня. Обратную связь менти собирает сам: проверяет равенство «сырье среза = годные + брак» по `_load_id`, а текущее состояние сверяет с `argMax` по `updated_at` из сырья. Ниже по течению результат проверяет `classes_vs_inventory` через DDS и витрину. Слабое место — ветвь брака. Чистый мир не производит брак, поэтому отрицание предиката останется непроверенным, пока менти сам не подложит по строке каждого класса. Готовый рецепт есть в `orders/ingestion.md`, «Риски и проверка»; после опыта данные нужно обязательно убрать.
**DDS сессия (`session_scope` / `rebuild` / `replace`).** Это лучший поток для вычитания: здесь гипотеза подтверждается без дополнительных условий. Эталон `VisitID` внешний, а `sessions_vs_reference.sql` считает чужой чекан и не повторяет алгоритм нарезки. Ошибиться можно в важных местах: в полном порядке окон, границе суток в `PARTITION BY`, детерминированном чекане и расчете объема из позиции мира. Эталон заканчивается на атрибутах строки. Сверка работает на зерне кука × день и проверяет только число сессий. Поэтому неверные границы, длительность, вход, выход или UTM могут пройти зеленым. `session_overview` из `homework_check` тоже согласится, потому что сверяет витрину с тем же слоем. Вторая ступень обратной связи — поштучная сверка границ по `VisitID` из сюжета 11, которую менти пишет сам.
**DDS заказ (`order_scope` / `rebuild` / `replace`).** Вычитание возможно при условии: сначала нужно построить недостающую проверку. Косвенный эталон уже есть. `classes_vs_inventory` ловит потерянные заказы и неверные статусы закрытых дней, а `purchase_vs_orders` проверяет `items_total`. Но независимо не проверяются разбор массивов `item_*` и время в поясе `Europe/Samara`. Выручка сойдется сама с собой, а счетчиков выручки в описи намеренно нет (спека, раздел 8). Стенд уже подсказывает решение: до вычитания нужно реализовать инвариант «цена × количество = items_total» из сюжета 14. Последовательность «сначала сторож, потом ломать» сама становится частью урока.
**DDS карта идентичностей (`identity_map_rebuild.sql`).** Для чистого вычитания этот поток не подходит: независимого эталона нет. `daily_traffic_vs_dds` читает карту с обеих сторон, поэтому пустая или задвоенная карта пройдет проверку. Счетчики идентичности в опись не входят (спека, раздел 8). Менти может сам написать слабые инварианты: `known_users <= visitors` и «каждая пара подтверждена покупкой и заказом». Они проверят допустимость пар, но не их полноту. Подходящие формы — «собери рядом и сверь со стендовой картой» либо вычитание вместе с самодельной проверкой, которая честно названа неполной.
**DM стендовые витрины.** Формально это самый защищенный участок: `dq_check` независимо пересчитывает каждую витрину. На деле проверка содержит готовый ответ: `revenue_daily_vs_dds.sql` дословно повторяет рецепт витрины. Вычитание возможно как сюжет 19, но только с предупреждением из `docs/labs/README.md` о цене подглядывания.
### Сводная таблица
| Поток | Отдать на переписывание | Несущая стена | Обратная связь | Где кончается эталон |
|---|---|---|---|---|
| События STG | ничего | чтец, сырье, `hits_raw_mv` | опись (счет) | все, кроме счета |
| События STG→ODS | «собери рядом», не вычитание | обе матвью, `*_errors` | стендовый ODS рядом, опись | содержимое неопорных колонок; окно сырья 3 суток |
| Заказы STG | ничего | чтец, забор | хеш слепка в описи | сам забор |
| Заказы STG→ODS | `order_parse.sql` | `*_errors`, чтец | баланс среза по `_load_id`, `argMax(updated_at)`, опись классов | ветвь брака — пока не подложен брак руками |
| DDS сессия | `session_*` (все три) | донор, DDL, даг | `sessions_vs_reference` (внешний эталон) | атрибуты строки — нужна поштучная сверка |
| DDS заказ | `order_*` (все три) | донор, DDL, даг | опись классов, `items_total` в сверке | разбор позиций и пояс — сначала сюжет 14 |
| DDS карта | только с оговорками | DDL, даг | нет независимой | почти сразу; форма — «собери рядом» |
| DM витрины | `revenue/pvo/traffic` | доноры, DDL, даг | `dq_check` целиком | ответ лежит в самой проверке |
### Вывод
Гипотеза «вычитание + DQ» подходит не для всех нижних слоев. Без оговорок она работает только для сессий DDS: там есть внешний эталон, настоящие места для ошибок и проверка, которая не содержит готового решения.
Для заказов DDS и разбора заказов ODS нужна последовательность «сначала построй сторожа, потом ломай». Недостающие инварианты — сюжеты 14 и 15, а также баланс среза — сами входят в учебный материал. Их нужно построить до вычитания.
STG и прием событий нельзя отдавать в форме «удали и перепиши»: код там служит несущей частью стенда. Для этих участков подходят расследования и форма «собери рядом».
У всех счетных проверок остается общая дыра — раскладка по шардам. Поэтому пересчет ключа по `hostName()` должен входить в ТЗ каждого вычитания отдельной строкой.
+149 -110
View File
@@ -138,8 +138,9 @@ Ecommerce (заполнены только у торговых событий):
| `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) | | `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) |
`add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав `add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав
заказа и блок `purchase*`. Выручка у клиента — во Float64, как у Метрики: заказа и блок `purchase*`. Выручка приходит JSON-числом Float64, как у
это не недосмотр, а часть урока о расхождениях (см. раздел 4). Метрики. ODS один раз нормализует деньги в `Array(Decimal(18, 2))`, не меняя
формат источника; DDS наследует тип хранения без нового вычисления.
### 1.3 Ключи и движки ### 1.3 Ключи и движки
@@ -210,9 +211,9 @@ Ecommerce (заполнены только у торговых событий):
ключей совпадает с порядком полей в таблице контракта, у позиции — `sku`, ключей совпадает с порядком полей в таблице контракта, у позиции — `sku`,
`qty`, `price`: так байты воспроизводимы без сортировки ключей. Разница с `qty`, `price`: так байты воспроизводимы без сортировки ключей. Разница с
кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами
и разбираются как `Array(Float64)`, а бэкенд передаёт деньги строками для Float64, а бэкенд передает деньги строками. На границе ODS оба источника
точного `Decimal`. Это показывает расхождение представлений денег в двух становятся `Decimal(18, 2)`: клиентское число разбирается сразу, без
источниках. промежуточного Float64 в хранилище.
Запись собирает явная функция существующего канонического сериализатора: Запись собирает явная функция существующего канонического сериализатора:
один словарь с вложенным списком и один `orjson.dumps`, без универсального один словарь с вложенным списком и один `orjson.dumps`, без универсального
слоя кодеков. слоя кодеков.
@@ -220,18 +221,21 @@ Ecommerce (заполнены только у торговых событий):
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md). [исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
- `ods.order` принимает версии заказа в - `ods.order` принимает версии заказа в
`ReplacingMergeTree(updated_at)`: `ORDER BY order_id`, партиция по дню `ReplacingMergeTree(snapshot_date)`: `ORDER BY order_id`, партиция по дню
неизменного `created_at`, шардирование по `cityHash64(order_id)`. неизменного `created_at`, шардирование по `cityHash64(order_id)`. Дата
`snapshot_date` остаётся датой наблюдения источника, но не задаёт публикацию слепка служит колонкой версии: выживает строка с максимальной
или идемпотентность. Физическое чтение может видеть несколько версий; `snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)).
`ods.order_v` возвращает точное текущее состояние. Устройство модели DDS и Дата не доказывает полноту принятой порции, поэтому ODS не заменяет партицию
способ её материализации решаются отдельно. слепка целиком. Физическое чтение может видеть несколько версий;
`ods.order_v` возвращает точное текущее состояние. В DDS одна строка хранит
один заказ с позициями в типизированных массивах; физическая модель и
пакетное наполнение описаны в [доке заказа](../architecture/dds/order.md).
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает. Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
- `items` остаётся сырой строкой JSON в ODS. Разбирать позиции следует на - `items` остаётся сырой строкой JSON в ODS. Позиции разбираются на границе
границе ODS → DDS, но их представление определяется вместе с будущей моделью ODS → DDS в типизированные массивы строки заказа. Это остаётся носителем
заказов. Это остаётся носителем навыка «вложенный JSON в ClickHouse», не навыка «вложенный JSON в ClickHouse», не превращая приём в преждевременную
превращая приём в преждевременную модель данных. модель данных.
- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания» - Статусы держим все три: смена `created` → `paid` и есть причина «дыхания»
выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна
заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда
@@ -261,18 +265,20 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
| | Расхождение | Механика в генераторе | Ориентир доли | | | Расхождение | Механика в генераторе | Ориентир доли |
|---|---|---|---| |---|---|---|---|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе | | A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% | | B | Потерянное событие | заказ есть, `purchase` не доехал | бросок 3%, наблюдаемых ~1,8%: часть маячков защищена парами склейки, часть уходит в отмену приоритетом |
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% | | C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% |
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% | | D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
Классы пересекаются — приоритет: `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 дней,
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и 2026-08-30, #194) — запись в «Что проверено» [карты
только по дням с закрытым окном (раздел 8). проверок](../architecture/testing.md); паспорт хранит счётчики наблюдаемых
классов после приоритета — в заказах и только по дням с закрытым окном (раздел
8).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) — Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
механически дублирует B. механически дублирует B.
@@ -282,7 +288,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM); под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
слово `declared` в имени — сигнал «это заявка клиента, не деньги слово `declared` в имени — сигнал «это заявка клиента, не деньги
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
раскопает почему (Float64 против Decimal, промокод, доставка, отмены). раскопает почему (состав позиций, промокод, доставка, отмены).
## 5. Анонимность и склейка идентичностей ## 5. Анонимность и склейка идентичностей
@@ -292,21 +298,27 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
- **Карта соответствий кука↔пользователь** строится трансформацией из уже - **Карта соответствий кука↔пользователь** строится трансформацией из уже
существующего моста: `purchase`-событие (`ClientID`, `purchaseID`) ↔ заказ существующего моста: `purchase`-событие (`ClientID`, `purchaseID`) ↔ заказ
(`order_id`, `user_id`). Ни новых полей, ни нового транспорта. (`order_id`, `user_id`). Ни новых полей, ни нового транспорта.
- Форма и место карты: таблица `dds.identity_map` - Форма и место карты: `dds.identity_map_rep` хранит пару `client_id` UInt64,
(`client_id` UInt64, `user_id` UInt64, `first_matched_at` DateTime), `user_id` UInt64 и координаты собственной загрузки `_load_id`, `_load_ts` в
ReplacingMergeTree, `ORDER BY (client_id, user_id)`, шардирование по `ReplicatedReplacingMergeTree(_load_ts)` с `ORDER BY (client_id, user_id)`.
`cityHash64(client_id)` — тем же выражением, что события (иначе ко-локации `dds.identity_map_dist` шардирует по `cityHash64(client_id)` — тем же
нет); ко-локация делает обогащение витрин локальным; выражением, что события; ко-локация позволяет обогащать витрины локально.
заказы при сборке карты подтягиваются через GLOBAL JOIN. Публичный интерфейс `dds.identity_map_v` перечисляет колонки явно и прячет
`FINAL`. `first_matched_at` в стенде нет: у него нет читателя; история
склейки оставлена самостоятельной работе в issue #145.
- Карта пересобирается пакетно после модели заказов: все `purchase` из
`dds.event_v` соединяются с текущими заказами `dds.order_v` через
`GLOBAL JOIN`, статус заказа связь покупателя с кукой не отменяет. Каждый
запуск пишет полный текущий набор различных пар; предыдущий результат не
служит источником следующего.
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») — - **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
параметр мира; значение фиксирует эта спека: 15%, детерминировано параметр мира; значение фиксирует эта спека: 15%, детерминировано от seed.
от seed. Ядро лабы: Ядро лабы: `uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам. Константа мира: каждый двухкуковый покупатель делает минимум по одному заказу
Константа мира: каждый двухкуковый покупатель делает минимум по одному с каждой куки — иначе вторая кука не попадает в карту соответствий (она
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий строится только из покупок) и лаба не воспроизводится. Число таких пар растёт,
(она строится только из покупок) и лаба не воспроизводится. Число таких пока мир едет, и в паспорт не кладётся ([что хранит
пар растёт, пока мир едет, и в опись не кладётся паспорт](../architecture/orders/inventory.md)).
([что хранит опись](../architecture/orders/inventory.md)).
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и - Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде. **«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
@@ -327,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 среди опорных колонок
@@ -357,19 +367,32 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы, ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
опечатка иначе не падает. опечатка иначе не падает.
- **Политика соединений**: по ключу ко-локации — обычное соединение с - **Политика соединений**: по ключу ко-локации — обычное соединение с
комментарием, почему локальный результат корректен; по любому другому ключу комментарием, почему локальный результат корректен; если публичные
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка представления скрывают ко-локацию — решение записывается рядом с витриной.
По любому другому ключу — только явный GLOBAL; `NOT IN` — только
`GLOBAL NOT IN`. Сверка
`purchase`↔заказ — `purchase`↔заказ —
легитимная GLOBAL-витрина (заказы малы). легитимная GLOBAL-витрина (заказы малы).
- **Конвейер без TRUNCATE**: поток версий — в ReplacingMergeTree, точное чтение - **Конвейер без TRUNCATE**: поток версий — в ReplacingMergeTree, точное чтение
— через `FINAL` или равносильный выбор последней версии; батчевая — через `FINAL` или равносильный выбор последней версии; батчевая
переобработка нижележащих объектов — по дневным партициям переобработка нижележащих объектов — по дневным партициям
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не (`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
применяется вовсе — полный сброс стенда делается `make clean && make up`, то применяется вовсе: обычная переобработка ограничена партицией, а полное
есть вместе с томами. `DROP/REPLACE PARTITION` работает только по восстановление данных — базами слоёв в следующем пункте. `DROP/REPLACE
**локальным** таблицам ON CLUSTER, не по Distributed; замена через PARTITION` работает только по **локальным** таблицам ON CLUSTER, не по
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это Distributed; замена через DROP+INSERT неатомарна — дашборд в середине прогона
осознанная цена, не баг). честно моргает (это осознанная цена, не баг).
- **Область очистки следует за областью состояния**: обычная разработка не
требует пустых томов. Сначала пересоздаётся затронутый объект, партиция или
база слоя; если нужен целиком чистый мир, пересоздаются прикладные базы
ClickHouse, топики и позиции их потребителей Kafka, затем заново заливаются
данные. Сами службы и их тома сохраняются. `make clean` нужен, только когда
требуется удалить состояние вне прикладного мира либо проверить запуск именно
с пустых томов. Перед таким сбросом агент называет это состояние и объясняет,
почему более узкой очистки недостаточно. «Так проще и надёжнее» не считается
обоснованием. Обязательный `make clean` перед каждой приёмкой отвергнут: он
стирает следы неисправности и превращает редкий холодный запуск в постоянную
цену разработки.
- **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их - **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их
подхватывают материализованные объекты DDS и DM, решается вместе с их моделью, подхватывают материализованные объекты DDS и DM, решается вместе с их моделью,
а не при проектировании приёма. а не при проектировании приёма.
@@ -411,34 +434,36 @@ README.
| STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг | | STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг |
| ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние | | ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние |
| ODS | `ods.order` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние | | ODS | `ods.order` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние |
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) | | DDS | `dds.session_rep` / `_dist`, `dds.session_v` | физическая сборка сессий и её публичный интерфейс |
| DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую | | DDS | `dds.event_v` | представление над `ods.event_v`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
| DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS | | DDS | `dds.order_rep` / `_dist`, `dds.order_v` | текущая модель заказа и её публичный интерфейс |
| DDS | `dds.identity_map` | карта кука↔пользователь | | DDS | `dds.identity_map_rep` / `_dist`, `dds.identity_map_v` | физическая карта кука↔пользователь и её точный публичный интерфейс |
| DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) | | DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) |
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже | | DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья — суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырых данных
см. [доку хранилища](../architecture/storage.md). — см. [доку хранилища](../architecture/storage.md). Потребительские запросы и
витрины читают сущности DDS через `_v` с явным списком колонок; `_rep` и `_dist`
служат загрузке, диагностике и обслуживанию. Договор держится конвенцией, а не
правами: модель доступа описана в [ADR 0007](../adr/0007-clickhouse-access.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` хранит версии по `updated_at`, пишет годные строки и ошибки из того же среза. `ods.order` выбирает текущую
а не публикует партицию `snapshot_date`. Слой сырья остаётся точкой повтора и версию по максимальной `snapshot_date` ([ADR
разбора одной принятой порции. 0014](../adr/0014-order-version-snapshot-date.md)). Слой Сырой слой остаётся
точкой повтора и разбора одной принятой порции.
Стенд получает от этого два режима приёма рядом, поток и слепок, и сравнение
из опорных точек раздела 12 переформулировано под них.
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия: Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но у `ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
заказа бизнес-версию задаёт `updated_at`; `_load_id` проходит через STG и обе заказа задаёт дата слепка; `_load_id` проходит через STG и обе цели ODS. Таблицы
цели ODS. Таблицы
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды — `stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
без них урок «какая нода читала топик» ненаблюдаем. без них урок «какая нода читала топик» ненаблюдаем.
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
@@ -447,8 +472,8 @@ README.
Kafka день переигрывается генератором заново: снимок — кэш чистой функции, Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
см. [спеку генератора](2026-08-01-generator.md). см. [спеку генератора](2026-08-01-generator.md).
`ods.event_v` сохраняет имена, типы и зерно ODS, но скрывает `FINAL` и возвращает `ods.event_v` сохраняет имена, типы и зерно ODS, но скрывает `FINAL` и
актуальную версию события. Будущее `dds.event_v` — другой контракт: оно возвращает актуальную версию события. `dds.event_v` — другой контракт: оно
переводит событие на язык бизнес-модели, в том числе даёт snake_case-имена и переводит событие на язык бизнес-модели, в том числе даёт snake_case-имена и
расшифровывает коды. Это первый на стенде пример правила «слой — это контракт, расшифровывает коды. Это первый на стенде пример правила «слой — это контракт,
а не обязательно копия данных». а не обязательно копия данных».
@@ -483,13 +508,20 @@ Kafka день переигрывается генератором заново:
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
`declared_revenue` по UTM. `declared_revenue` по UTM.
- **`daily_traffic_v`** — расширяется парой «посетители» / «известные - **`daily_traffic_v`** — расширяется парой «посетители» / «известные
пользователи» (обогащение через `dds.identity_map`, локальное соединение пользователи». Обогащение идет явным `GLOBAL LEFT JOIN` через
по ключу ко-локации). `dds.identity_map_v`: опыт на ClickHouse 26.3 показал, что публичные `_v`
скрывают ко-локацию от локальной подстановки.
- `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`, - `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`,
`dq_errors_daily_v` — переезжают на новую модель без смены роли: источник — `dq_errors_daily_v` — переезжают на новую модель без смены роли. Источник
`dds.event_v`, не `ods.event`. и зерно у двух из них меняет сама новая модель, и это зафиксировано ТЗ
- `dm.dq_summary` переводится с TRUNCATE+INSERT на партиционную замену домашек ([`docs/labs/`](../labs/README.md)):
(политика «без TRUNCATE»). `session_overview_v` читает `dds.session_v` и поднимается на зерно «день ×
срез», потому что визит стал сущностью DDS и витрина его зерна была бы
копией сессий; `dq_errors_daily_v` читает таблицы `ods.*_errors`, у
которых в DDS представительства нет. Остальные две читают `dds.event_v`,
а не `ods.event`.
- `dm.dq_summary` хранит текущее качество дня и обновляется партиционной
заменой (политика «без TRUNCATE»).
Дашборд Superset получает три новых сюжета: выручка по дням и категориям, Дашборд Superset получает три новых сюжета: выручка по дням и категориям,
таблица сверки с классами расхождений, пара посетители/известные. таблица сверки с классами расхождений, пара посетители/известные.
@@ -498,30 +530,31 @@ Kafka день переигрывается генератором заново:
пачками), Postgres остаётся только служебной базой Airflow. Смешанность пачками), Postgres остаётся только служебной базой Airflow. Смешанность
ландшафта выражена режимами и частотами, а не второй трубой. ландшафта выражена режимами и частотами, а не второй трубой.
## 8. Эталонный мир и опись ## 8. Эталонный мир и паспорт
В git хранится только опись эталонного мира; сам снимок (14 модельных В git хранится только паспорт эталонного мира; сам снимок генерируется на месте
дней) генерируется на месте — при `make up` и при проверках (решение — при `make up` и при проверках (решение развилки «Производительность» карты
развилки «Производительность» карты #26, подробности — #26, подробности — [спека генератора](2026-08-01-generator.md), раздел 5).
[спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней снято на этапе
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики 7. Паспорт несёт шапку с каноническим зерном и версией генератора, контрольные
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман карты
карты #10) закрыта этим же ходом: версионируется опись. #10) закрыта этим же ходом: версионируется паспорт. Контрольные числа паспорта:
Контрольные числа описи:
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание - по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями; «слепок переснимается и даёт те же байты» паспорт сторожит наравне с днями;
- заказная сторона: заказы, выручка и счётчики наблюдаемых классов - заказная сторона: счётчики наблюдаемых классов расхождений после
расхождений после приоритета, в заказах — по дням с закрытым окном приоритета; каждый показывает число заказов и хранится только для дня с
(самопроверка лабы сверки; при N сыгранных днях таких дней N − 7); закрытым окном (читатель — техническая проверка `classes_vs_inventory` звена
DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для общего числа
заказов и выручки в паспорте пока нет: для них не назван читатель;
- контрольные числа идентичности не заводятся: uniq известных пользователей - контрольные числа идентичности не заводятся: uniq известных пользователей
и число двухкуковых покупателей растут, пока мир едет, а счётчик без и число двухкуковых покупателей растут, пока мир едет, а счётчик без
названного читателя в опись не кладётся названного читателя в паспорт не кладётся ([что хранит
([что хранит опись](../architecture/orders/inventory.md)). паспорт](../architecture/orders/inventory.md)).
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
при пересборке. [карты проверок](../architecture/testing.md).
## 9. Оценка объёма исполнения ## 9. Оценка объёма исполнения
@@ -535,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 |
@@ -582,9 +615,9 @@ 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-генерация
на amd64 и arm64. снята (спека генератора, раздел 5).
8. Superset-дашборд v2. 8. Superset-дашборд v2.
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов 9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
и границы — ADR 0002. Этап идёт не подряд, см. ниже. и границы — ADR 0002. Этап идёт не подряд, см. ниже.
@@ -607,9 +640,11 @@ ADR 0002 с приёмочными совпадают почти целиком.
Что гонять на приёмке этапа — строка в [карте Что гонять на приёмке этапа — строка в [карте
проверок](../architecture/testing.md), раздел «Какую проверку когда проверок](../architecture/testing.md), раздел «Какую проверку когда
запускать»: стенд собирается с нуля и на нём зелены все три цели, которым запускать»: на стенде зелены все три цели, которым он нужен, а состояние перед
нужен стенд. Это гигиена, а не приёмка: она обещает, что этап не сломал стенд, проверкой восстанавливается в самой узкой достаточной области. Запуск с пустых
и молчит о его предмете — дашборд может быть не нарисован, а все три останутся томов обязателен только для изменения начальной сборки или постоянного состояния
служб, а не как общая гигиена этапа. Эти цели обещают, что этап не сломал стенд,
и молчат о его предмете — дашборд может быть не нарисован, а все три останутся
зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в
/to-tickets. Документация правится в PR этапа (правило AGENTS.md). /to-tickets. Документация правится в PR этапа (правило AGENTS.md).
@@ -619,9 +654,15 @@ ADR 0002 с приёмочными совпадают почти целиком.
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом. расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной - Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
переносится. Спека даёт будущим лабам только опорные точки — контрольные переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг самопроверки
числа описи (сверка, идентичность). Явное следствие: после этапа 9 забраны в этап 4 резолюцией #135, потому что витрина менти и конвейер, в
стенд работает, но учебного пути на нём ещё нет. который она встает, проектируются вместе. Сюда же — необязательная лаба «свой
дашборд в Superset» (#197): она стоит на витринах домашек DM и продумана до
готового ТЗ в этапе 8, тогда как остальные лабы пока на уровне идей. Единый
стиль блока домашек — за работой учебного пути. Спека даёт будущим лабам
только опорные точки — контрольные числа паспорта (сверка, идентичность).
Явное следствие: после этапа 9 стенд работает, но учебного пути на нём ещё
нет.
- Инкрементальный ETL (#8) — свой issue. - Инкрементальный ETL (#8) — свой issue.
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд. - Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные - TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
@@ -657,8 +698,6 @@ Kafka Engine на двух нодах снят с этого списка при
`ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо `ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо
обезврежено, — работа без ответа на конце. обезврежено, — работа без ответа на конце.
- Поведение соединения двух Distributed-таблиц и `distributed_product_mode` —
эмпирически на стенде (хвост #14).
- Размер артефакта эталонного мира после пересборки. - Размер артефакта эталонного мира после пересборки.
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом - Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
сверять через MCP Context7 (правило AGENTS.md). сверять через MCP Context7 (правило AGENTS.md).
File diff suppressed because it is too large. Load diff
+264
View File
@@ -0,0 +1,264 @@
# Жизненный цикл мира: создание и пересоздание ведёт Airflow
Дата: 21 августа 2026 года. Решение и доводы — [ADR 0013](../adr/0013-world-lifecycle.md);
здесь устройство, которое из него следует.
## Задача
До этой работы начало оси мира собирали трое. Compose создавал топики, применял
DDL и двумя одноразовыми службами заливал восемь дней. Shell-сценарии ждали,
пока события доедут до ODS, и разово дёргали даг приёма заказов. Airflow вёл
мир после стартовой заливки.
Граница владения не совпадала с процессом, и это видно по следствиям. Успешная
отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу
после записи в топик, до приёма сообщений хранилищем. Повторный `make up`
смешивал подъём служб с повторной заливкой данных. Узкая пересборка хранилища
сохраняла в Kafka сообщения и офсеты прежнего мира, потому что чинила только
ClickHouse. Позиция на оси правилась снаружи Airflow — тем, кто миром не
управляет.
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
прикладная схема, данные мира и позиция модельного времени, — и Compose,
который держит только инфраструктуру. Зеленая ветка создания в
`world_initialize` подтверждает: схема создана, оба источника приняты,
следующий день известен, слои DDS и DM собраны.
## Решение
Граница владения одна: **Compose отвечает за инфраструктуру; Airflow — за
прикладной мир.**
Compose держит долгоживущие службы, сети, тома, объявленные файлом учётные
записи ClickHouse, инициализацию метаданных Airflow и инициализацию Superset.
Airflow держит прикладные топики Kafka, канонический DDL прикладных баз `stg`,
`ods`, `dds`, `dm`, `dic`, данные стартового мира, их приём и переменную
`world_position`.
Одноразовых служб `kafka-init`, `clickhouse-init`, `world-init` и
`world-snapshots` в графе Compose не остаётся. Ни профиля, ни вызова через
`compose run` им не оставляют: спрятанная за профилем служба — тот же второй
оркестратор, только менее заметный.
`make` инфраструктурой и ограничивается: поднимает стенд, зовёт нужный даг
через REST API Airflow и ждёт результата. Shell больше не применяет DDL, не
запускает генератор и не правит позицию.
### Учётная запись жизненного цикла
Прикладную структуру создаёт и удаляет отдельная учётная запись `lifecycle` с
ролью `lifecycle_owner`, объявленная тем же файлом, что остальные учётные записи
([ADR 0007](../adr/0007-clickhouse-access.md)). Ей разрешено администрировать
прикладные базы — заводить и удалять их и объекты внутри, — читать их и
выполнять кластерный DDL. Ей разрешено чтение `system.settings`: без него
драйвер `clickhouse-connect` под официальным провайдером Airflow не открывает
соединение. Административные запросы к системным базам ей запрещены.
`etl` при этом не расширяется. Право удаления баз нужно редкой операции
восстановления и остаётся у `lifecycle`. Граница проверяема: при лишнем запросе
менти увидит в ошибке имя учётной записи и недостающее право.
### Что Airflow получает от репозитория
Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`,
`data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого
собирается канонический образ генератора, из второго читается паспорт мира, а
последний не пускает в контекст сборки локальные окружения и кэши. Запросы
стенда приезжают прежним монтированием `sql/`.
Меняющиеся API сверены через Context7 и затем с установленными пакетами
стенда. Выбраны публичный REST API v2 и ждущий `TriggerDagRunOperator` Airflow,
сборка образа через `DockerHook.get_conn().build()`, futures `AdminClient` для
Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов ClickHouse и
`ClickHouseHook` для запросов, чей результат обрабатывает Python. Оператор сам
шаблонизирует файл и при `split_statements=True` исполняет выражения по порядку;
собственный разбор SQL дагу больше не нужен.
## Контракты дагов
Оба дага публичные: без расписания, не на паузе, `max_active_runs = 1`. Их
запускают руками из интерфейса и командой `make` через REST API.
### `world_initialize` — создать мир, если его нет
Даг безопасен по построению: на готовом мире он ничего не меняет.
1. Читает `world_position` и решает, что делать дальше: инициализировать,
успешно завершиться без изменений или покраснеть. Разбор состояний — в
таблице ниже.
2. Создаёт топики: `hits` с двумя партициями, `orders` с одной. Топики идут
**до** DDL: чтец ClickHouse начинает читать топик, когда над ним появляется
матвью, и топик, созданный брокером сам, приехал бы с одним разделом
([ADR 0008](../adr/0008-order-ingestion.md)).
3. Применяет канонический DDL из `sql/ddl/` по порядку имён файлов, с ноды 1,
учётной записью `lifecycle`. Каждый файл — отдельная последовательная
задача `SQLExecuteQueryOperator`; выражения внутри неё исполняются по
порядку с `split_statements=True`. Красная задача называет файл и не пускает
последующие. Правило «матвью приёма создаётся последней» остаётся за
раскладкой файлов и не переезжает в даг.
4. Собирает канонический образ генератора. Иначе мир мог бы приехать из старого
кода: сборкой образа при подъёме больше никто не занимается.
5. Играет стартовые дни одним прогоном `batch` того же образа.
6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего
сценария, и оно точное: сумма строк `ods.event_dist` за даты паспорта и строк
`ods.event_errors_dist` не меньше суммы событий из
[`data/world-inventory.json`](../../data/world-inventory.json). «Что-то
приехало» условием не считается.
7. Отправляет слепки заказов вторым прогоном образа — со сдвигом, дни 0…6
([заказы в стартовом мире](../architecture/orders/start-world.md)).
8. Дергает `orders_ingest` ждущим триггером и продолжает только после его
успеха.
9. Ставит позицию в `WORLD_STARTING_DAYS`: источники приняты, мир уже создан.
10. Последним шагом дергает `etl_pipeline` ждущим триггером. Конвейер
последовательно ждет `dds_transform` и `dm_transform`; отказ слоя красит
инициализацию.
Порядок задач связывает проверки: каждый шаг подтверждён предыдущим, а красная
задача называет место остановки. Ветка "мир уже существует" конвейер не
запускает: обычный повтор `make up` по-прежнему ничего не меняет.
### `world_recreate` — снести мир и создать заново
Сначала даг выполняет всю разрушительную часть, затем делегирует создание мира.
1. Ставит позицию в `-1`: прежний мир объявлен недействительным раньше, чем с
ним что-то произошло.
2. Удаляет прикладные базы ClickHouse `stg`, `dm`, `dds`, `ods`, `dic` — первым
делом, чтобы чтецы Kafka перестали существовать вместе с ними.
3. Удаляет позиции групп потребителей `clickstream_hits` и
`clickstream_orders`, затем топики `hits` и `orders`.
4. Ставит позицию в `0` — только после успешной очистки.
5. Дёргает `world_initialize` ждущим триггером и ждёт его результата.
**Общего модуля с шагами двух дагов нет.** Переиспользование здесь — это
делегирование: пересборка вызывает создание мира целиком. Такой модуль стал бы
третьим местом с той же последовательностью, а первое же расхождение между
дагами пришлось бы прятать в его параметрах.
### Сторожа у шаговых дагов
`world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше
`WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг:
поднять стенд или пересобрать хранилище. Выключатель `world_live` своего сторожа
не получает — он краснеет вслед за шаговым дагом.
`orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда
готового мира ещё нет.
## Состояния позиции
`world_position` остаётся единственным состоянием дагов модельного времени.
| Позиция | `world_initialize` | `world_recreate` | Шаговые даги |
|---|---|---|---|
| переменной нет | создаёт мир | очищает и создаёт | красный отказ |
| `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ |
| `0` | создаёт мир | очищает и создаёт | красный отказ |
| `WORLD_STARTING_DAYS` и больше | мир уже есть: пишет это в журнал и зеленеет | очищает и создаёт | работают |
Значения между `1` и `WORLD_STARTING_DAYS` штатно не возникают: позиция
становится либо `-1`, либо `0`, либо номером сыгранного дня за стартовым миром.
Попав на такое значение, `world_initialize` краснеет тем же отказом, что на
`-1`, и называет прочитанное число.
## Поведение при отказах
**Пересборка упала на очистке.** Позиция остаётся `-1`. Обычный `make up` эту
поломку не заглаживает: `world_initialize` краснеет и говорит, что мир
недействителен и пересборку надо повторить. Стенд обязан назвать незавершённую
очистку вслух и не продолжать работу молча.
**Очистка прошла, создание остановилось до установки позиции.** Позиция
остаётся `0`. Инициализация детерминирована, поэтому следующий `make up` просто
пробует её заново — с того же места и с тем же результатом.
**ETL упал после установки позиции.** Мир и оба источника уже готовы, поэтому
позиция остается в `WORLD_STARTING_DAYS`. После исправления запускают рукой
`etl_pipeline`: он сам выводит объем из состояния данных. Повторный
`world_initialize` здесь не поможет: при готовой позиции инициализация уйдет в
короткую ветку без изменений.
**Позицию в `WORLD_STARTING_DAYS` ставит только успех обоих источников.**
События доехали, слепки приняты, `orders_ingest` зелёный — тогда и только тогда
мир считается созданным.
**Порядок очистки Kafka входит в контракт.** Пока живы чтецы
ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо.
Поэтому сначала удаляются базы, затем топики.
**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: число
строк STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на
непустом ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
делает себе сам и тут же видит.
**Слоты Airflow.** Их четыре. При пересборке `world_recreate` ждет
`world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик. Поэтому
пересборка — явная операция обслуживания: оператор ставит `world_live` на паузу
и даёт шаговым дагам доиграть. Предусловие остаётся в документации; механизм
блокировок стоил бы дороже этой строки.
## Границы
- **`make up` не мигратор схемы.** На готовом мире изменённый DDL не
применяется. Обновляет схему явная пересборка `world_recreate` либо
адресная правка объекта в разработке. Размен принят с открытыми глазами.
- **Ни блокировок, ни статуса, ни автоматической паузы.** Предусловие
обслуживания названо словами.
- **`etl` не расширяется**, `orders_ingest` не получает сторожа.
- **`make clean` сохраняет свой смысл**: полное удаление постоянного состояния
стенда. Ни в пересборку, ни в обычную проверку он не входит.
- **`make generate-batch` и `make generate-live` остаются** — с них снимаются
унаследованные зависимости от исчезающих одноразовых служб.
- Содержание DDL, код генератора и устройство слоёв не меняются.
## Проверка
Цели `make` — обычные: `config-test` и `lint` без стенда, `smoke`,
`check-clickhouse` и `check-services` на поднятом. Сверх них проверяются
сценарии, которых у стенда раньше не было.
- **Повторный `make up` на живом мире** проходит быстро и ничего не меняет:
позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что
мир уже есть.
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты паспорта, топики созданы
заново, офсеты групп идут с нуля, DDS и DM собраны.
- **Красные пути проверяются своими отказами.** Сторож шагового дага при
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
доказывает только то, что умеет печатать «ЗЕЛЁНО».
- **Отказ в правах читается.** Учётная запись `lifecycle` применяет весь DDL
без единой ошибки прав, а административный запрос к системной базе ей
отклоняют — и текст отказа называет учётку.
- **Один холодный `make clean && make up`** оправдан на итоговой приёмке и
только этой работой: она меняет самый первый подъём стенда, а его иначе не
увидеть. Обобщать этот случай не нужно — обычная проверка по-прежнему
обходится узкой областью очистки.
Замеры холодного и повторного `make up` и пересборки записываются в
[карту проверок](../architecture/testing.md), раздел «Что проверено».
## Отклонённые варианты
- **Спрятать одноразовые службы за профилем или звать их `compose run`.**
Дешевле всего в правках, но оставляет двух оркестраторов одного процесса —
ровно то, ради чего затевался переезд.
- **Оставить применение DDL и заливку мира в shell, а Airflow отдать только
позицию.** Тогда «один наблюдаемый процесс» остаётся обещанием: половина
графа живёт в журнале терминала.
- **Вынести общие шаги двух дагов в отдельный модуль.** Третье место для той же
последовательности; расхождение дагов пряталось бы в его параметрах.
- **Применять изменённый DDL при каждом `make up`.** Это мигратор схемы; ему
нужен отдельный разговор, в граф создания мира он не помещается.
- **Выдать права на удаление баз учётной записи `etl`.** Отвергнуто ещё
ADR 0013: конвейер не получает административные права из-за редкой операции.
- **Завести блокировку или статус обслуживания, чтобы пересборка не
сталкивалась с движением мира.** Механизм на редкий ручной случай дороже
предусловия, названного словами.
- **Сторож на `orders_ingest`.** Даг зовут до того, как мир существует; сторож
запретил бы саму инициализацию.
+321
View File
@@ -0,0 +1,321 @@
# Программа учебных лаб
Эта спека собирает программу лаб из
[инвентаризации сюжетов](../research/2026-08-31-labs-inventory.md), выполненной
в тикете #201. Тикет спеки — #202, карта — #182.
Статус: спека принята владельцем; судьба идей-кандидатов решена здесь поименно.
## 1. Кому и зачем
Менти уже прошел большой теоретический курс ClickHouse объемом около 60 часов.
Он знает устройство системы в теории, но почти ничего не делал своими руками.
Поэтому в программе нет вводного блока с теорией. Здесь теория становится
ремеслом: менти руками осваивает слои хранилища, DQ, оркестрацию и
идемпотентность.
Обещание программы: менти проходит конвейер стенда автором, а не читателем.
## 2. Устройство программы: основная программа и факультативы
У частей программы два статуса, третьего нет.
**Основная программа** — обязательная часть. Менти проходит ее целиком и по
порядку, потому что каждая следующая ступень опирается на предыдущую. От
ступени к ступени растет самостоятельность: сначала менти получает готовые ТЗ с
готовыми сторожами (DQ-проверками), а затем строит сторожей сам.
**Факультативы** — необязательные лабы. Они собраны в тематические разделы,
чтобы менти мог одним взглядом выбрать интересное. Для каждого раздела указано,
после какой ступени основной программы он открывается.
В программе используются четыре формы заданий:
- *построить свое по ТЗ* — рядом есть образец, но готового ответа нет;
- *вычитание* — SQL готовой трансформации удаляется и пишется заново, а даги
остаются как несущая инфраструктура;
- *собери рядом* — менти строит свою копию результата рядом с нетронутым
стендовым и сверяет их построчно;
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а
менти ищет причину и объясняет ее.
### Как писать задания
Пишите для учащегося: объясняйте, чему он научится, зачем нужны действия
и как понять результат. Историю разработки оставляйте за пределами задания.
«Как проверить себя» описывает действия для проверки и поиска ошибок.
«Что должно получиться» — результат работы и то, что учащийся теперь может
объяснить. Если проверка уже встроена в шаги упражнения, повторять ее
отдельным разделом не нужно.
Остальные заголовки и структуру выбирайте по содержанию. Образцы — задания
про [дашборд Superset](../labs/optional/superset-dashboard.md) и
[расследование подключения](../labs/optional/superset-missing-object.md).
## 3. Основная программа — семь ступеней
### Ступень 1. Построй пять витрин слоя DM
Первая часть программы опирается на шесть существующих заданий из
[`docs/labs/`](../labs/README.md). Пять ТЗ по витринам входят в первую ступень
основной программы и выполняются как есть. Их точечный пересмотр — работа
реализации. Шестое задание, про свой дашборд в Superset, остается факультативом
в разделе «Superset и BI». Набор уроков пяти витрин уже записан в их общем
описании: от слоя как договора до честных имен показателей.
Форма — построить свое по ТЗ. Обратную связь дает даг `homework_check`.
### Ступень 2. Свой отчет в Superset
Менти строит один график в Superset поверх витрины, которую сделал на первой
ступени. Урок — увидеть, как собственный результат из хранилища становится
отчетом в BI, не превращая знакомство с инструментом в отдельный большой
проект.
Форма — построить свое по ТЗ. Результат проверяется глазами. ТЗ для этой новой
ступени предстоит написать при реализации программы. Полный собственный дашборд
остается факультативом в разделе «Superset и BI».
### Ступень 3. Нарежьте сессии сами
До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно
делит события на визиты. Сначала он сравнивает число своих сессий с эталоном
`VisitID`. Затем вручную соединяет свою сессию с эталонной и поштучно разбирает
хотя бы одно расхождение границ. Урок — счет называет проблему, но причину
приходится искать в отдельных строках.
Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит
из счетной сверки с `VisitID` и ручного сопоставления границ.
### Ступень 4. Перепишите нарезку стенда
Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку
и замену. Урок — изменить расчетную сущность внутри готового конвейера и
проверить ее внешним эталоном. Это единственный поток, для которого
инвентаризация подтвердила вычитание без оговорок: здесь есть настоящие места
для ошибок, а проверка не содержит готового ответа.
Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка,
построенная на третьей ступени.
### Ступень 5. Научите проверки замечать ошибки в заказах
Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство
«цена × количество = items_total». Вторая сверяет слой с источником по дням. Их
менти строит по образцу пяти стендовых проверок в `sql/dq/`. Обе проверки
должны покраснеть на управляемой ошибке. Затем менти вручную подкладывает по
строке каждого класса брака, проверяет разбор и убирает эти строки за собой.
Только после этого он вычитает разбор заказов ODS `order_parse` и трансформацию
заказа DDS. Урок — сторожа строят до рискованного изменения, а не после первой
поломки.
Форма — построить свои проверки по ТЗ, провести управляемое расследование и
выполнить вычитание. Обратную связь дают две новые проверки, баланс годных и
ошибочных строк по срезу и существующие сверки заказов.
### Ступень 6. Разбери сырые события и сверь результат со стендом
Менти пакетно разбирает срез событий из STG в собственную таблицу и построчно
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
отдельно от живого приема и использовать стендовый результат как образец для
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
сообщения.
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
границы называются прямо: срок работы ограничен окном хранения сырых данных, а
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
независимым эталоном.
### Ступень 7. Мониторинг: куда смотреть
Сначала менти знакомится с панелями стенда. Затем проводит два безопасных
расследования. В первом он находит в `query_log`, что верх списка тяжелых
запросов занимает сам мониторинг. Во втором объясняет, почему свежесть второго
источника выглядит как пила, а не как линия, и выбирает правильный слой данных
для панели.
Форма — расследование. Обратная связь — найденные запросы и верное объяснение
формы графика и выбора слоя. Ломательные опыты остаются в факультативе «Что
делать, когда стенд сломался». Урок ступени — куда и на что смотреть в живой
системе.
**Общее правило вычитания:** ТЗ каждого вычитания на ступенях 4 и 5 включает
пересчет ключа шардирования по `hostName()`. Счетные сверки читают `_dist`,
поэтому учитывают строку даже на чужом шарде и остаются зелеными: они не видят
саму ошибку раскладки.
## 4. Факультативы — пять разделов
Ниже перечислены только названия сюжетов и их уроки. Материал, формы и способы
проверки остаются в [инвентаризации](../research/2026-08-31-labs-inventory.md).
### Почему выручка не сходится
Этот раздел рекомендован первым из факультативов. Он открывается после ступени
1 основной программы: материал живет на стенде с этапа 6.
- **Почему трекер недосчитывает покупки (#186).** Разрыв воронки подтверждений
показывает потерянные события и границу доверия к двум источникам.
- **Почему повтор покупки завышает выручку (#187).** Движок убирает технический
дубль строки, а повтор одной покупки приходится находить по деловому ключу.
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
разложить до конкретной вычеркнутой позиции и объяснить до копейки.
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
некоторые различия после истечения срока хранения сырых данных уже не
восстановить.
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
становится окончательным лишь после того, как источник больше не может
изменить этот день.
- **Почему выручка за день еще меняется.** Внутри окна изменяемости выручка
движется, а после закрытия дня фиксируется.
### Тонкий анализ данных
Открывается после ступени 1 основной программы.
- **Что мы знали о пользователе в тот день (#145).** Текущее знание и знание на
дату дают разные отчеты; сведения из будущего меняют прошлое.
- **Добавь названия регионов в отчет (#143).** Менти собирает свой словарь по
образцу каталога и обогащает отчет через `dictGet`.
### Superset и BI
Открывается после ступени 2 основной программы.
- **[Свой дашборд](../labs/optional/superset-dashboard.md).** Менти использует
результат ступени 2, собирает отчет из пяти своих витрин и подтверждает
выводы запросами. Пользовательские объекты остаются отдельно от стендовых.
- **[В DBeaver работает, в Superset нет](../labs/optional/superset-missing-object.md).**
Менти создает отдельное представление на первой ноде и расследует ошибку
чтения из Superset, подключенного ко второй. Опыт заканчивается объяснением,
исправлением DDL и удалением учебного объекта.
Задания самостоятельны: расследование использует готовую витрину стенда.
Отсутствие объекта проверяется в SQL Lab, поскольку создание физического
датасета может остановиться раньше, чем появится график. Чтение только одного
шарда остается материалом инженерного раздела: это отдельная причина
неполного результата, которую добавление `ON CLUSTER` само по себе не устраняет.
Технический опыт проверен 2026-09-13 на ClickHouse 26.3.17.56: локальное
представление недоступно пользователю `bi` на второй ноде; после создания
на кластере обе ноды возвращают тот же счет, что исходная витрина.
### Инженерия стенда
Открывается после ступени 1 основной программы. Сюжеты про переигрывание дня и
пошардовую атомарность раскроются полнее после ступени 4, когда менти уже
спускался в DDS.
- **Как принимать данные из Kafka: потоком или слепком.** Поток и слепок
требуют разных способов приема и дают разную наблюдаемость.
- **Почему данные попадают на свои шарды независимо от ноды чтения.**
Нода-потребитель может меняться, а раскладку строк определяет ключ
шардирования.
- **Почему вторая группа потребителей дублирует поток.** Еще одна группа
потребителей создает еще одну полную копию потока.
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
показывает потерю, которую трудно заметить по самим данным.
- **Почему сырые данные удаляются по времени загрузки.** Очистка сырых данных
идёт по
времени загрузки, а не по модельному дню.
- **Куда попадают битые строки.** Годные строки и брак делят поток без
пропусков и пересечений; менти подкладывает по строке каждого класса брака
событий и находит их в `ods.event_errors_dist`.
- **Какая версия заказа считается текущей.** Версию заказа задает слепок
источника, а `FINAL` скрывается за представлением.
- **Почему один момент попадает в разные дни.** Один момент дает разные дни в
разных поясах, поэтому день и пояс нужно называть явно.
- **Проверь, как стенд разобрал JSON в `ecommerce`.** Типизированный разбор
может молча подменить ошибку значением, поэтому менти сам разбирает сырой
JSON и сравнивает результат с массивами стенда.
- **Как конвейер восстанавливает пропущенный день.** Конвейер сам находит дыру
и определяет объем повторной обработки по состоянию хранилища.
- **Что будет, если день заменился не на всех шардах.** Замена дня завершается
отдельно на каждом шарде, поэтому целостность результата подтверждает
отдельная сверка.
- **Как обновлять витрину без полной пересборки (#149) — повышенная
сложность.** Экономия на пересчете требует хранить историю изменений, которую
полная пересборка стирает.
### Что делать, когда стенд сломался
Открывается после ступени 7 основной программы; весь раздел — повышенная
сложность.
- **Что ломается вместе с keeper.** Остановка keeper связывает состояние DDL и
репликации с тем, что видно на панелях и в системных таблицах.
- **Где искать запрос DDL, который не завершился.** Очередь распределенного DDL
показывает путь запроса и ответ каждой ноды.
Итог раздела — runbook, которого сейчас не хватает стенду. Менти пишет его по
результатам опытов, и документ остается в проекте.
## 5. Границы вычитания по потокам
Подробное сравнение потоков и таблица доказательств остаются в
[инвентаризации](../research/2026-08-31-labs-inventory.md). Спека берет это
сравнение за основу и проводит свои границы. Без оговорок вычитанию отдаются
только сессии DDS. Заказы можно вычитать лишь после самодельных сторожей. Для
событий ODS выбрана форма «собери рядом»; вычитание здесь отклонено. Прием STG
и забор заказов остаются несущей стеной и доступны только для расследований.
Карту идентичностей спека решает не отдавать вычитанию; причины этого выбора
названы в следующем разделе.
## 6. Что не вошло
Вычитание стендовых витрин DM не входит в программу. Оно повторяет урок
готового ядра DM, а открытые сверочные запросы в `sql/dq/` почти дословно
содержат ответ.
Форма «собери рядом» для карты идентичностей тоже не входит. Независимого
эталона нет, а слабые инварианты проверяют допустимость связей, но не полноту.
Аналитический урок идентичности уже несет склейка во времени (#145) в
факультативе «Тонкий анализ данных».
Сюжеты, которые инвентаризация относит к лекциям, остаются лекциями: `Sign` и
CollapsingMergeTree, топологии в бою и идентичность «как в бою». Эта спека
оставляет их в
[списке опорных точек раздела 12 спеки v2](2026-07-30-stand-v2-realism.md#опорные-точки-для-будущих-лекций-и-лаб).
## 7. Судьба идей-кандидатов
Эти решения закрывают тикеты после приемки спеки:
- #143, справочник регионов — вошла в факультатив «Тонкий анализ данных».
- #145, склейка во времени — вошла в факультатив «Тонкий анализ данных».
- #148, домашки ниже DM и вычитание — вошла в основную программу как ступени
4–6. Форму для каждого потока уточнила инвентаризация #201: вычитание,
«сначала сторож» или «собери рядом».
- #149, инкрементальный `daily_traffic` — вошла в факультатив «Инженерия
стенда» с пометкой «повышенная сложность».
- #186, воронка подтверждений — вошла в факультатив «Почему выручка не
сходится».
- #187, бизнес-дубль — вошла в факультатив «Почему выручка не сходится».
- #188, опоздавшие заказы — вошла в факультатив «Почему выручка не сходится».
## 8. Отклоненные варианты оси
- **Чистое «снизу вверх»: STG → ODS → DDS → DM.** Оно начинает программу с
самого тяжелого по инфраструктуре участка. Внизу быстро кончается эталон
обратной связи, а вычитание вслепую заставляет переписывать слой, которым
менти еще не пользовался.
- **Два прохода: сначала расследования снизу вверх, потом постройка сверху
вниз.** Пролог из восьми расследований откладывает первый собственный
результат. Вместо этого расследования разнесены по факультативам и
открываются по мере прохождения основной программы.
- **Сюжетами поперек слоев.** Такой порядок ломает принятое решение, по
которому готовые задания открывают программу, и превращает путь в сетку
зависимостей.
## 9. Границы спеки и что дальше
Спека не переписывает существующие ТЗ в `docs/labs/` и общий README; точечный
пересмотр относится к реализации.
Реализация программы тоже находится за границей спеки. В нее входят новые ТЗ
для ступеней 2–7 основной программы и факультативов, а также отражение порядка
программы в `docs/labs/README.md`. Карту #182 приемка этой спеки закрыла;
исполнение ведет отдельная карта — тикет #206 (решение тикета #203).
+6 -1
View File
@@ -1,4 +1,4 @@
.PHONY: lint typecheck test docs inventory .PHONY: lint typecheck test demo docs inventory
# --- Проверки --- # --- Проверки ---
@@ -12,6 +12,11 @@ typecheck:
test: test:
uv run pytest uv run pytest
# --- Пример из путеводителя ---
demo:
uv run python -m clickstream_generator.demo
# --- Сборка из кода --- # --- Сборка из кода ---
docs: docs:
+14 -30
View File
@@ -13,31 +13,11 @@
## Как это работает ## Как это работает
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
> выверен: пишет его тот, кто держит устройство генератора в голове, а это дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48. а день-функция проживает один день без накопленного состояния. Код этой
цепочки, ее схема и запуск D0 собраны в
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве [путеводителе](../docs/architecture/generator/README.md).
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
история для него не нужна и на него не влияет.
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
значит сдвинуть все броски после него, а с ними и весь мир: события того же
дня станут другими, счётчики канонического мира разойдутся с описью, и
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
`plan.cohort` брошены последними, а личность человека приписана уже за ними:
куки, пары и паспорта остались теми же до байта.
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
распределения numpy расходятся между версиями и архитектурами, а обещано
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
`rng.choice` с вероятностями.
## Что где лежит ## Что где лежит
@@ -84,9 +64,10 @@ D0 живёт предыстория, поэтому любой день соб
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки» - `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md)) ([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
из контракта. Документ руками не правят — пересобирают. из контракта. Документ руками не правят — пересобирают.
- `src/clickstream_generator/inventory.py` — сборка описи мира - `src/clickstream_generator/inventory.py` — сборка паспорта мира
([`data/world-inventory.json`](../data/world-inventory.json)): паспорт мира ([`data/world-inventory.json`](../data/world-inventory.json)): шапка паспорта
и хеши восьми дней, которыми наполняется стенд. Руками не правят — и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов
расхождений по заказам для дней с закрытым окном. Руками не правят —
пересобирают целью `make inventory`. пересобирают целью `make inventory`.
- `tests/` — инварианты контракта, свежесть описания и обещания мира: - `tests/` — инварианты контракта, свежесть описания и обещания мира:
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
@@ -96,15 +77,18 @@ D0 живёт предыстория, поэтому любой день соб
## Команды ## Команды
Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`. Проиграть день — раздел [«Как запустить
генератор»](../docs/architecture/world.md#как-запустить-генератор)
справочника мира и `--help`.
Из этого каталога — цели генератора живут здесь, а не в корне: Из этого каталога — цели генератора живут здесь, а не в корне:
- `make test` — тесты генератора; - `make test` — тесты генератора;
- `make lint` — ruff: проверка и формат; - `make lint` — ruff: проверка и формат;
- `make typecheck` — ty: проверка типов; - `make typecheck` — ty: проверка типов;
- `make demo` — пройти стадии канонического D0 на одном экране;
- `make docs` — пересобрать описание выгрузки; - `make docs` — пересобрать описание выгрузки;
- `make inventory` — пересобрать опись мира. - `make inventory` — пересобрать паспорт мира.
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
Superset. Одноимённые цели за разными дверями охватывают разное: Superset. Одноимённые цели за разными дверями охватывают разное:
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more