Author SHA1 Message Date
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
97 changed files with 5834 additions and 901 deletions

No files matched your search

+4
View File
@@ -157,6 +157,10 @@ Airflow) и названия из кода. Если для понятия ес
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
истории, а текущее описание живого формата — и собирается из кода, а не истории, а текущее описание живого формата — и собирается из кода, а не
пишется руками. пишется руками.
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх - Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
в имени не нужна. в имени не нужна.
+2 -2
View File
@@ -118,8 +118,8 @@ ClickHouse. Форма файла решена, длина — нет: стро
**Стартовый мир**: **Стартовый мир**:
Первые восемь дней оси (понедельник по понедельник), которыми стенд Первые восемь дней оси (понедельник по понедельник), которыми стенд
наполняется при каждом `make up`. Маленький кусок эталонного мира, всегда наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
один и тот же: на нём принимаются следующие этапы. на нем принимаются следующие этапы.
_Избегать_: зерновой мир _Избегать_: зерновой мир
**Опись мира**: **Опись мира**:
+5
View File
@@ -262,6 +262,11 @@ uv run --project generator python -m clickstream_generator batch \
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники `0` — очистка закончена, а начальная генерация ещё не завершена. Работники
начинают играть только с позиции `8` и больше. начинают играть только с позиции `8` и больше.
В форме запуска можно выбрать только число дней. Первый день работник берет из
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
позицию работник пишет в журнал шага `remember_played`.
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно — Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
целиком — [ADR 0009](docs/adr/0009-world-control.md). целиком — [ADR 0009](docs/adr/0009-world-control.md).
+10 -5
View File
@@ -48,10 +48,10 @@ x-kafka-target: &kafka-target
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"
@@ -105,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}
@@ -257,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:
@@ -310,7 +310,10 @@ services:
- ./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
@@ -337,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
-5
View File
@@ -279,11 +279,6 @@ def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
# Два прогона разом собирали бы один и тот же двойник: второй затёр бы # Два прогона разом собирали бы один и тот же двойник: второй затёр бы
# чужую сборку между вставкой и заменой партиций. # чужую сборку между вставкой и заменой партиций.
max_active_runs=1, max_active_runs=1,
# Потолок на задачи в прогоне. Размноженная замена норовит выстрелить
# всеми днями сразу, а каждая задача — свой процесс исполнителя, и
# планировщику отведено 640 МиБ (compose.yaml). Замерено: тринадцать дней
# двух сущностей без потолка кончаются SIGKILL от контрольной группы.
max_active_tasks=3,
template_searchpath=str(SQL_ROOT), template_searchpath=str(SQL_ROOT),
tags=["dds"], tags=["dds"],
) )
+91 -9
View File
@@ -1,8 +1,9 @@
"""Трансформации слоя DM: сборка стендовых витрин. """Трансформации слоя DM: сборка стендовых витрин.
Выручка наследует ритм заказов и заменяет дневные партиции изменяемого окна. Выручка наследует ритм заказов. Сверка соединяет окно заказов с прожитым
Трафик наследует ретроспективность карты идентичностей и пересчитывает всю хвостом событий. Обе заменяют дневные партиции. Трафик наследует
историю новой версией. Объем каждого прогона выводится из состояния слоев. ретроспективность карты идентичностей и пересчитывает всю историю новой
версией. Объем каждого прогона выводится из состояния слоев.
""" """
from __future__ import annotations from __future__ import annotations
@@ -14,12 +15,41 @@ from pathlib import Path
from airflow.exceptions import AirflowException from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.sdk import dag, task, task_group from airflow.sdk import Variable, dag, task, task_group
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC) START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
SQL_ROOT = Path("/opt/airflow/sql") SQL_ROOT = Path("/opt/airflow/sql")
CLICKHOUSE_CONNECTION = "clickhouse_default" 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]: def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
@@ -103,17 +133,69 @@ def dm_transform():
conn_id=CLICKHOUSE_CONNECTION, conn_id=CLICKHOUSE_CONNECTION,
sql="dm/revenue_daily_replace.sql", sql="dm/revenue_daily_replace.sql",
do_xcom_push=False, do_xcom_push=False,
# Airflow 3 применяет этот лимит и к размноженным экземплярам
# (сверено через Context7). Каждая замена - отдельный процесс
# LocalExecutor; три разом исчерпали память контейнера scheduler,
# а самим дням параллельность ничего не дает.
max_active_tis_per_dag=1,
).expand_kwargs(replacements(days)) ).expand_kwargs(replacements(days))
rebuild >> replace rebuild >> replace
revenue_daily() 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( SQLExecuteQueryOperator(
task_id="daily_traffic", task_id="daily_traffic",
conn_id=CLICKHOUSE_CONNECTION, conn_id=CLICKHOUSE_CONNECTION,
+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()
+12 -2
View File
@@ -1,4 +1,4 @@
"""Дневной конвейер: сначала DDS, затем DM. """Дневной конвейер: DDS, DM и проверка качества.
Своих преобразований у дага нет. Ждущие триггеры связывают состояния: Своих преобразований у дага нет. Ждущие триггеры связывают состояния:
если слой краснеет, конвейер не запускает следующий слой и краснеет сам. если слой краснеет, конвейер не запускает следующий слой и краснеет сам.
@@ -36,8 +36,18 @@ def etl_pipeline():
wait_for_completion=True, wait_for_completion=True,
poke_interval=10, 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 dds >> dm >> dq
etl_pipeline() 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/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/top-pages-daily.md",
"top_pages_daily_vs_dds.sql",
)
check_mart.override(task_id="session_overview")(
"dm.session_overview_v",
"docs/labs/session-overview.md",
"session_overview_vs_dds.sql",
)
check_mart.override(task_id="dq_errors_daily")(
"dm.dq_errors_daily_v",
"docs/labs/dq-errors-daily.md",
"dq_errors_daily_vs_ods.sql",
)
check_mart.override(task_id="utm_effectiveness")(
"dm.utm_effectiveness_v",
"docs/labs/utm-effectiveness.md",
"utm_effectiveness_vs_dds.sql",
)
homework_check()
+25 -3
View File
@@ -28,6 +28,7 @@
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.exceptions import AirflowException
@@ -141,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 "
"и на выбор дня не влияют."
),
) )
}, },
) )
@@ -149,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(
@@ -196,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])
+25 -23
View File
@@ -6,92 +6,94 @@
{ {
"day": 0, "day": 0,
"date": "2026-06-01", "date": "2026-06-01",
"events": 50626, "events": 50628,
"sha256": "81ad7f402bdd4c8b56425f6439a024b9996e5ca95dd207d444290d20a70ed407", "sha256": "d09704ed99e45824e451e2f6bbbbd5c547df382f7a582699955010f3807eb0ee",
"orders": { "orders": {
"match": 197, "match": 192,
"cancelled": 20, "cancelled": 20,
"lost_event": 3,
"duplicate_event": 2,
"amount_delta": 6 "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"
} }
] ]
} }
+7
View File
@@ -16,6 +16,13 @@
возвращает единицу — поведение проверено при исполнении #37. Зависимость возвращает единицу — поведение проверено при исполнении #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`. Новый именованный том принадлежит
+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 не резервирует разницу между
пиком и пределом, поэтому этот запас не увеличивает расход памяти в покое.
+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.
+5
View File
@@ -45,6 +45,11 @@
своё имя здесь создало бы третий синоним. `client_timezone` тоже остаётся — своё имя здесь создало бы третий синоним. `client_timezone` тоже остаётся —
это атрибут посетителя, а не линза времени. это атрибут посетителя, а не линза времени.
`purchase_revenue` сохраняет смысл поля источника. Тип денег нормализуется в
ODS; точный тип и проверка описаны в разделе
[«Актуальные события»](../storage.md#актуальные-события). DDS только
переименовывает уже нормализованную колонку.
## Расшифровки ## Расшифровки
Одна: `device_category` — коды 1–4 в строки `desktop` / `mobile` / Одна: `device_category` — коды 1–4 в строки `desktop` / `mobile` /
+6 -6
View File
@@ -35,10 +35,9 @@
ломают. ломают.
`session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка: `session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка:
витрины сессий читают `dds.session_v`, их зерно совпадает с зерном модели, и в в соединениях он не участвует — события его не несут, мост к заказам живёт
соединениях id не участвует — события его не несут, мост к заказам живёт на на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со
событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со стендовой и стендовой и назвать расхождение одним числом) и разбор DQ-расхождений.
назвать расхождение одним числом) и разбор DQ-расхождений.
## Состав строки ## Состав строки
@@ -110,8 +109,9 @@
## Читатели ## Читатели
- Витрина обзора сессий `session_overview_v`: длительности, глубина, - Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
вход/выход; читает `dds.session_v`, зерно совпадает (#135). срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
в ее [ТЗ](../../labs/session-overview.md) (#135).
- DQ-сверка с эталоном (см. выше). - DQ-сверка с эталоном (см. выше).
- Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда, - Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда,
потом сравнивает — с эталоном и со стендовой. потом сравнивает — с эталоном и со стендовой.
+102 -41
View File
@@ -39,14 +39,16 @@
образцом. Разминка — представление `events_enriched_v` (зерно события), образцом. Разминка — представление `events_enriched_v` (зерно события),
затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`, затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`,
последней — самая содержательная `utm_effectiveness` с уроком `declared_*`. последней — самая содержательная `utm_effectiveness` с уроком `declared_*`.
Построенная витрина встаёт в конвейер руками менти: финальный пункт Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
каждого ТЗ — добавить задачу своей витрины в `dm_transform`. До этого каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
наравне со стендовыми. наравне со стендовыми. У разминочного представления такого пункта нет:
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
от выполненных домашек. Расширение дашборда визуализациями по своим от выполненных домашек. Свой дашборд по витринам менти —
витринам — отдельная домашка после этапа 8. [необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
стендового дашборда — в [доке зоны Superset](superset.md).
## Ритм пересборки ## Ритм пересборки
@@ -70,9 +72,17 @@
знать, какие связки новые, а полный пересчёт карты этот след затирает — знать, какие связки новые, а полный пересчёт карты этот след затирает —
восстановление следа оставлено лабе повышенной сложности (issue #149). восстановление следа оставлено лабе повышенной сложности (issue #149).
У `purchase_vs_orders` два источника и одна непрерывная пересборка. Заказная
сторона задает изменяемое окно последним принятым слепком ODS, событийная —
последний прожитый день с `purchase`. Левая граница возвращает первую дыру и
самый ранний день с `awaiting_order`: предварительная строка уже есть в
витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница
доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный
разгон длиннее K и не оставляет старое ожидание замороженным.
## Витрины стенда ## Витрины стенда
Оба публичных представления перечисляют деловые колонки и координаты запуска Публичные представления перечисляют деловые колонки и координаты запуска
`_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не `_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не
попадет. попадет.
@@ -98,6 +108,29 @@
шесть категорий стартового мира близки по объему. Более сложный составной ключ шесть категорий стартового мира близки по объему. Более сложный составной ключ
распределил бы эту маленькую таблицу без нового урока. распределил бы эту маленькую таблицу без нового урока.
### `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` ClickHouse заполнил бы ее нулем или пустой строкой и скрыл
отсутствие источника. Клиентская `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` ### `dm.daily_traffic_v`
В строке один день. `visitors` считает разные `client_id` из В строке один день. `visitors` считает разные `client_id` из
@@ -118,53 +151,81 @@
## `dm.dq_summary` ## `dm.dq_summary`
Зерно — «день данных × проверка»: строка отвечает, каково качество дня Зерно публичного `dm.dq_summary_v` — «день данных × атомарная проверка».
сейчас, а не сколько раз его проверяли, — история прогонов и так живёт в Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
Airflow. Партиция — день данных; прогон звена DQ заменяет партиции Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
проверенных дней через `REPLACE PARTITION`. Обещанный спекой перевод с
`TRUNCATE+INSERT` выполняется той же механикой замены, что и у витрин, —
ничего отдельного учить не надо.
## Будущее звено DQ конвейера Деловые колонки сводки:
Встанет в `etl_pipeline` после DM ([конвейер](etl.md)) и проверит только - `data_date` — проверяемый день;
объекты стенда: - `check_name` — устойчивое имя инварианта;
- `reference_rows` — число деловых ключей на эталонной стороне;
- `actual_rows` — число деловых ключей в проверяемом объекте;
- `failed_rows` — число отсутствующих или различающихся ключей;
- `status` — `pass` при нуле нарушений, иначе `fail`.
- счётная сверка сессий с эталоном `VisitID` — кука × день, эталон читается Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление
из `ods.event_v` ([модель сессии](dds/session.md)); перечисляет колонки явно. Порогов и промежуточного состояния нет.
- счётчики классов расхождений против описи мира — по дням с закрытым
окном. Проверка техническая, не урок DQ: её оракул — опись генератора,
а у настоящего магазина оракула нет. Обещание узкое и честное: до
витрин доехал тот мир, который родился, — одна растяжка через весь
путь вместо сторожей на каждом шаге. Верно ли сам мир рождён, она
не видит: опись считает тот же генератор;
- счётные сверки витрин стенда с их DDS-источниками.
Результаты ложатся в `dm.dq_summary`. Звено — не только сторож конвейера, ## Звено 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`.
Область каждой проверки выводится из объединения сторон. Полностью потерянный
день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут
скомпенсировать друг друга общим итогом: `failed_rows` считается после
сопоставления деловых ключей.
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет
сам генератор: у настоящего магазина такого оракула нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow
только сводку и малый срез диагностики. Техническая сверка с описью читает
небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их
успеха даг заменяет проверенные дневные партиции. Затем именованные задачи
читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
сохраниться до красного завершения, а ошибка сборки не затирает прежний
результат. Красная задача печатает не больше двадцати нарушенных ключей за
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
business_key`.
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
Такой день удаляется из сводки отдельно: старая красная строка не должна
превращаться в историю попыток.
## Даг самопроверки ## Даг самопроверки
Домашки контролирует отдельный даг без расписания: менти запускает его Домашки контролирует `homework_check` — отдельный даг без расписания: менти
рукой. Задача витрины красная, пока витрины нет или счёт не сходится, и запускает его рукой. По задаче на каждое из
зелёная, когда построено верно; сообщение об ошибке говорит, что не так и [пяти заданий по витринам](../labs/README.md).
куда смотреть. Проверки — инварианты против DDS-источников, золотые Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
константы не прибиваются. В `dq_summary` даг не пишет: он инструмент лабы, построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
а не история качества конвейера. золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
лабы, а не история качества конвейера.
Приём проверен на соседнем учебном стенде Airflow + Greenplum: Приём проверен на соседнем учебном стенде Airflow + Greenplum:
валидационный даг с группами проверок по слоям давал менти обратную связь валидационный даг с группами проверок по слоям давал менти обратную связь
по каждому шагу домашки, не трогая рабочий конвейер. по каждому шагу домашки, не трогая рабочий конвейер.
## Остаётся нарезке
- Имена звена DQ и дага самопроверки.
- Ключи шардирования остальных материализованных витрин — вместе с их
DDL; правило партиции-донора — в [доке хранилища](storage.md).
- Тексты учебных заданий и точный список проверок обоих контуров.
- Механика сверки A+C в SQL — рамка в спеке, разделы 4 и 7.
## Отклонённые варианты ## Отклонённые варианты
- **Весь слой представлениями.** Дёшево и всегда свежо, но главный - **Весь слой представлениями.** Дёшево и всегда свежо, но главный
+8 -8
View File
@@ -13,8 +13,8 @@
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы
у него нет: он дёргает слойные даги последовательно и ждёт каждый — у него нет: он дёргает слойные даги последовательно и ждёт каждый —
`dds_transform`, затем `dm_transform`. Будущее звено DQ встанет после DM. `dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в
Его состав описан в [доке слоя DM](dm.md). [доке слоя DM](dm.md).
Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по
ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что
@@ -28,7 +28,7 @@
```text ```text
world_initialize ─┐ world_initialize ─┐
world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transform world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transform ─> dq_check
запуск рукой ─────┘ запуск рукой ─────┘
world_next_day ─────> источники без ETL world_next_day ─────> источники без ETL
@@ -137,11 +137,11 @@ ODS и ещё не собранный в DDS, затем при необходи
## Не входит ## Не входит
- Партиционная переобработка — переиграть произвольный прошлый день или - Партиционная переобработка — переиграть произвольный прошлый день или
диапазон, включая форму необязательных параметров того же дага, — этап 5 диапазон, включая форму необязательных параметров того же дага.
(#7); туда же стык #111 про дату модельного мира и `logical_date`. - Инкрементальный ETL вместо пересборки отклонен (#2, wontfix). Идея отдельной
- Инкрементальный ETL вместо пересборки — техдолг #2. лабы на эту тему — #149.
- Состав витрин `dm_transform` и звена DQ — [дока слоя DM](dm.md); - Состав витрин `dm_transform` и проверок `dq_check` —
имена задач — нарезка этапа 4. [дока слоя DM](dm.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).
+4 -2
View File
@@ -21,8 +21,10 @@ ClickHouse, два подготовленных источника данных,
| `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` | | `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` |
| `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` | | `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` |
Плагин ClickHouse 4.20.0 входит в образ Grafana. Фоновая установка плагинов Плагин ClickHouse 4.20.0 входит в образ Grafana. При сборке Grafana CLI берёт
при запуске отключена. закреплённый выпуск из официального репозитория на GitHub, а не из каталога
Grafana: прямой запрос к каталогу при проверке вернул HTTP 451. Фоновая
установка плагинов при запуске отключена.
## Кого опрашивает Prometheus ## Кого опрашивает Prometheus
+2 -10
View File
@@ -27,8 +27,8 @@
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` — не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
[судьба заказа](fate.md). [судьба заказа](fate.md).
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока — - **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
заказная и событийная стороны; броски независимы, пересечения выходят `ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
арифметикой, приоритет классов работает по-настоящему — пересечения выходят арифметикой, приоритет классов работает по-настоящему —
[классы расхождений и опоздание](fate.md). [классы расхождений и опоздание](fate.md).
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов - **Опись хранит только то, чего движение мира не меняет**: хеш байтов
каждого слепка и счётчики наблюдаемых классов — каждого слепка и счётчики наблюдаемых классов —
@@ -51,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, к
+30 -25
View File
@@ -16,11 +16,10 @@
и `LATECOMERS` решения не переживают — они названы по классам витрины, а и `LATECOMERS` решения не переживают — они названы по классам витрины, а
компонент называет часть мира; новые стороны занимают те же позиции. компонент называет часть мира; новые стороны занимают те же позиции.
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2; Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
событийная получит имя вместе с содержимым на этапе 6, до тех пор позиция 3 событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
занята мёртвым именем прежней нарезки. Отклонено: *один компонент на всю отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
судьбу* — правка событийной механики молча меняла бы байты слепка; событийной механики молча меняла бы байты слепка; *компонент на класс* —
*компонент на класс* — пять имён под ручки калибровки, которые крутятся пять имён под ручки калибровки, которые крутятся разом.
разом.
**Правило формы, без которого разделение не работает: броски заказной стороны **Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
@@ -56,8 +55,11 @@
часа покупки, и менти нашёл бы этот наклон первым же разрезом. часа покупки, и менти нашёл бы этот наклон первым же разрезом.
**Доли.** Ориентир мастер-спеки ~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 и физические версии
@@ -108,12 +110,13 @@
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где один в один — посчитал наивно, удвоил выручку. Дубль случается только там,
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у где пауза до следующего визита куки длиннее таймаута и задержки дубля вместе:
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID` иначе сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон,
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита, ради которого `VisitID` в потоке лежит. Задержка дубля —
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с секунды-минуты, короче таймаута визита, поэтому `VisitID` тот же. Полночь
покупкой, по тому же правилу, что у подтверждения с торговым хвостом. режет дубль парой — просмотр вместе с покупкой, по тому же правилу, что у
подтверждения с торговым хвостом.
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* — Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
потеря перестаёт быть видна со стороны трекера. потеря перестаёт быть видна со стороны трекера.
@@ -127,18 +130,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 — сломалась бы та
самая сверка, ради которой всё строится.
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет **Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
+12 -9
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 — ночная выгрузка
+24 -7
View File
@@ -221,6 +221,13 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
`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` хранит принятые версии в
@@ -289,7 +296,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
@@ -483,9 +490,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)).
Устройство обоих объектов — почему такой движок, такая форма пути, такой Устройство обоих объектов — почему такой движок, такая форма пути, такой
пользователь и такое окно — расписано в пользователь и такое окно — расписано в
@@ -523,7 +530,7 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
| `40-stg-views.sql` | матвью приёма: чтец в сырьё | | `40-stg-views.sql` | матвью приёма: чтец в сырьё |
| `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций | | `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
| `50-dds-views.sql` | публичные договоры сущностей DDS | | `50-dds-views.sql` | публичные договоры сущностей DDS |
| `55-dm-tables.sql` | физические витрины выручки и трафика, донор партиций выручки | | `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
| `60-dm-views.sql` | публичные договоры витрин DM | | `60-dm-views.sql` | публичные договоры витрин DM |
Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
@@ -594,8 +601,14 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
| `dm` | `dm.revenue_daily_rep` / `_dist` | дневная выручка по категориям | | `dm` | `dm.revenue_daily_rep` / `_dist` | дневная выручка по категориям |
| `dm` | `dm.revenue_daily_stage_rep` / `_dist` | таблица-двойник, донор дневных партиций выручки | | `dm` | `dm.revenue_daily_stage_rep` / `_dist` | таблица-двойник, донор дневных партиций выручки |
| `dm` | `dm.revenue_daily_v` | публичный договор дневной выручки | | `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_rep` / `_dist` | версии дневных счетчиков трафика |
| `dm` | `dm.daily_traffic_v` | точные текущие счетчики трафика с `FINAL` | | `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` | словарь товаров поверх подложки |
@@ -605,8 +618,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции; остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
карта пишет полный набор пар в версионную таблицу. В DM выручка заменяет карта пишет полный набор пар в версионную таблицу. В DM выручка и сверка
партиции изменяемых дней, а трафик пишет полную историю новой версией. заменяют партиции изменяемых дней, а трафик пишет полную историю новой версией.
## Что проверено ## Что проверено
@@ -849,6 +862,10 @@ Kafka с пустым значением (ноль байт) и запись-н
промежуточного `toDecimal64`. Проверено на живом слепке; честность разбора промежуточного `toDecimal64`. Проверено на живом слепке; честность разбора
сторожит инвариант бэкенда — сумма «цена × количество» совпала с `items_total` сторожит инвариант бэкенда — сумма «цена × количество» совпала с `items_total`
у всех 1662 заказов. у всех 1662 заказов.
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
ClickHouse 26.3; все суммы после ODS совпали с сырьем до копейки.
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие **Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
матвью разбора, закрыты опытами при исполнении #43 и переехали выше. матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
+165
View File
@@ -0,0 +1,165 @@
# 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`–`103` у графиков, `201` у дашборда
(`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные;
их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо
обновления первой.
Датасет несет русские имена колонок, формат денег и метрику — а график только
ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а
не в каждом графике.
Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит
представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и
представлением не знает, а виртуальный датасет со своим SQL просто повторил бы
запрос, который уже живет в `sql/ddl/60-dm-views.sql`.
## Импорт при каждой инициализации
Последняя строка `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 — но не тем, что команда отработала.
**`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики
берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки
исчезнет при следующем старте контейнера. Правка датасета — это правка файла.
## Что проверяет стенд
`make check-services` входит в Superset, находит подключение `ClickHouse`,
сверяет его uuid и безопасный URI и требует схему `dm` — то есть подтверждает
живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не
проверяет: их смотрят глазами после переинициализации. Полная карта целей — в
[доке проверок](testing.md).
+104 -10
View File
@@ -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` — 9 августа, `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` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
| `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` две, и это не опечатка.** Одноимённые цели за
разными дверями охватывают разное: корневая не видит ни одного файла разными дверями охватывают разное: корневая не видит ни одного файла
@@ -120,10 +121,11 @@ ClickHouse отвечает сразу.
из каталога `generator/`: стенд им не нужен, а `make test` из них самая из каталога `generator/`: стенд им не нужен, а `make test` из них самая
дорогая. Правки дагов и Superset — корневой `make lint`. дорогая. Правки дагов и Superset — корневой `make lint`.
Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
получается мир, его надо получить. Место выбрано по той же оси «кого узнать, тот ли получается мир, его надо получить. Место выбрано по той же
спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не
нужен, — и
краснеет проверка там, где надо: сразу после правки генератора. Не будь её, краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
поломкой хранилища, а не забытой пересборкой. поломкой хранилища, а не забытой пересборкой.
@@ -237,6 +239,98 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
## Что проверено ## Что проверено
**Калибровка заказной стороны 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 минут. **Приемка #156 27 августа 2026 года.** `make rebuild-storage` занял 7 минут.
На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался
`etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли `etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли
+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 товаров события |
+109
View File
@@ -0,0 +1,109 @@
# Учебные задания: витрины слоя DM и дашборд в Superset
Пять домашних заданий — про витрины слоя DM. Стенд привозит слой
наполовину: витрины трех сюжетов дашборда и контроль качества построены,
остальные витрины строит менти. Каждое из них — самостоятельное ТЗ: что за
витрина, какое у нее зерно, из чего она собирается и как проверить, что
собралась верно.
Шестое задание — необязательная лаба в Superset. В ней менти собирает свой
дашборд поверх пяти витрин.
Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на
вопросы «почему так», задание отвечает на вопрос «что сделать».
## Порядок
Задания идут по нарастанию: каждое следующее опирается на то, что уже
усвоено.
| № | Результат | Чему учит |
|---|---|---|
| 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных |
| 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер |
| 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит |
| 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни |
| 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение |
| 6 | [Свой дашборд в Superset](superset-dashboard.md) — необязательная лаба | свои объекты BI живут отдельно от стендовых |
## Общие правила
Они одни для пяти заданий по витринам, поэтому записаны здесь, а не
повторяются в каждом ТЗ.
**Читать — через `_v`.** Публичный договор сущности DDS — представление с
суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только
его; физические `_rep` и `_dist` служат загрузке и обслуживанию. Соседнюю
домашку витрина источником тоже не берет: витрина слоя DM читает договор
DDS, и задания остаются независимыми. Исключение одно и оно оговорено в
задании 4: у таблиц брака публичного договора нет.
**Писать — только в `_dist`.** При записи в локальную таблицу раскладку
определяет то, какая нода случайно выполняла код, а не свойство данных.
Правило и его следствия — [дока хранилища](../architecture/storage.md),
«Раскладка по шардам».
**Форма — по зерну.** Витрина, меняющая зерно источника, материализуется:
`dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / `_stage_dist` и
публичное `dm.<имя>_v` с явным списком колонок. Витрина, сохраняющая зерно,
остается обычным представлением. Довод — [дока DM](../architecture/dm.md),
«Форма: по зерну, не по цене запроса».
**Ключ шардирования — твое решение.** Ни одно ТЗ ключа не называет.
Правило выбора и требование к донору — [дока
хранилища](../architecture/storage.md), «Раскладка по шардам»: донор
объявляется через `AS <цель>_rep` и шардируется тем же ключом, что цель,
иначе замена партиции потеряет строку на одном шарде и задвоит на другом.
Названный ключ надо уметь защитить: почему он не дает перекоса и почему
более сложный тут ничего не добавляет. Образец такой защиты —
`dm.revenue_daily_dist` в [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
**Где лежит код.** DDL домашек — свой файл `sql/ddl/70-labs.sql`: он
применяется после стендовых по порядку имени, и твои объекты не мешаются
со стендовыми. У агрегатов рядом со стендовыми ложатся еще три запроса —
`sql/dm/<витрина>_scope.sql` спрашивает у хранилища, какие дни пересобирает
запуск, `_rebuild.sql` собирает их в донор, `_replace.sql` заменяет
партицию, — и задача в
[`dags/dm_transform.py`](../../dags/dm_transform.py). У разминочного
представления нет ничего из этого: весь его код — одно `CREATE VIEW`.
**Образец на все случаи** — витрина `revenue_daily`: у нее есть все пять
объектов, донор, сборка, замена дневных партиций и задача в конвейере.
Списывать с нее устройство можно и нужно; списывать состав колонок — нет,
он у каждой витрины свой.
## Самопроверка
Даг `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/` открыто, и
имена называют витрину и роль сверки. Проверка на инвариантах неизбежно
близка к ответу: чтобы сверить витрину, надо ее пересчитать. Прятать эти
файлы никто не станет — но открыв их до того, как задание сделано, ты
обменяешь урок на готовый SQL.
Смотреть туда стоит после своей сборки: сравнить два решения полезно,
списать одно — нет.
## Уборка
Черновики и опыты за собой сносить: правило проверок стенда —
[дока тестирования](../architecture/testing.md). Снесенная витрина обязана
снова покраснить свою задачу в `homework_check` — если не покраснила,
проверка ничего и не проверяла.
+118
View File
@@ -0,0 +1,118 @@
# Задание 4: `dm.dq_errors_daily`
**Чему учит:** у брака свой день и свой срок жизни. Витрина качества живет
по времени загрузки, а не по модельному дню, и переживает собственный
источник — поэтому «пересобрать все» здесь означает «стереть историю».
## Роль витрины
Сводка приемки: сколько строк за день не прошли разбор и по какой причине.
Читают ее, когда качество потока просело: сначала смотрят, вырос ли класс
брака, потом идут за сырым текстом в саму таблицу ошибок.
Витрина отвечает на вопрос «сколько и чего», а не «что именно сломалось».
Разбирательство с конкретной строкой начинается в `ods.event_errors_dist` и
`ods.order_errors_dist`, где рядом с классом лежат сырой текст и координаты
доставки.
## Источник: единственное исключение из правила `_v`
Общее правило домашек — читать через публичный договор `_v`. У таблиц брака
такого договора нет, и заводить его ради этой витрины не надо: брак — не
сущность бизнес-модели, а диагностика слоя приема. Читать нужно
распределенные таблицы напрямую:
- `ods.event_errors_dist` — брак разбора событий;
- `ods.order_errors_dist` — брак разбора слепка заказов.
Именно `_dist`, а не `_rep`: локальная таблица вернет строки одного шарда, и
витрина тихо покажет половину брака. Ошибки при этом не будет — ошибка
появится позже, когда по витрине примут решение.
## Зерно и форма
Зерно — **день загрузки × источник × класс брака**. Зерно источника —
строка брака, значит зерно сменилось и витрина материализуется:
`dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
публичное `dm.dq_errors_daily_v`. Ключ партиции — день загрузки.
День здесь — `toDate(_load_ts)`, то есть настоящее время, когда строка
приехала в хранилище, а не модельный день мира. Модельного дня у брака нет
и быть не может: он живет в содержимом, а содержимое как раз и не
разобралось.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `load_date` | `Date` | день загрузки, в который приехал брак |
| `source` | `LowCardinality(String)` | `event` или `order` |
| `error_class` | `LowCardinality(String)` | класс брака из таблицы ошибок |
| `error_rows` | `UInt64` | число забракованных строк |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
Две похожие колонки рядом различаются намеренно: `load_date` — про день,
когда приехал брак, `_load_ts` — про запуск, который собрал витрину. Первая
деловая, вторая служебная, и совпадать они не обязаны.
Классы у двух источников свои и не пересекаются по смыслу, поэтому колонка
`source` в зерне обязательна: без нее одинаковое имя класса у событий и у
заказов слилось бы в одну строку.
## Ритм пересборки: витрина переживает источник
Таблицы брака живут месяц (`TTL`), сырье — трое суток. Витрина живет без
срока: сводка «сколько брака было в марте» нужна и после того, как сами
строки марта удалены.
Отсюда правило прогона: пересобираются **только те дни загрузки, которые
еще есть в источнике**. Спроси у источника список живых дней и замени
партиции ровно этих дней. Соблазн «собрать все заново в донор и подменить
таблицу целиком» здесь стоит дороже обычного: истекшие по `TTL` дни исчезли
бы из витрины навсегда, а вместе с ними — единственная оставшаяся память о
них.
Текущий день загрузки не замерзает до конца суток: поток событий идет, и
брак в него еще добавится. Это нормально, день просто пересоберется на
следующем прогоне.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Витрина крошечная: классов брака у событий и заказов вместе меньше
десятка. Учти это в защите ключа — иногда правильный ответ звучит как
«перекос здесь ничего не стоит, и вот почему».
## Как проверить себя
Деловой ключ — пара `source` и `error_class` внутри `load_date`. Область
проверки — дни загрузки, которые еще живы хотя бы в одной таблице ошибок:
день за границей `TTL` сверять не с чем, и его отсутствие в источнике не
ошибка.
Собери прямой счет по обеим таблицам ошибок, соедини с
`dm.dq_errors_daily_v` полным внешним соединением по ключу и посчитай
строки, где ключа нет с одной из сторон или где расходится `error_rows`.
Ноль — зеленый.
Отдельно: сумма `error_rows` по живым дням равна общему числу строк в
`ods.event_errors_dist` плюс `ods.order_errors_dist`. Если эта сумма вышла
примерно вдвое меньше — витрина почти наверняка собрана с `_rep` вместо
`_dist` и видит один шард из двух.
**Пустая витрина — верный ответ.** На чистом стенде брака нет, обе стороны
пусты, расхождений ноль, задача зеленая. Проверка, которая требует хотя бы
одной строки, на здоровом стенде краснела бы всегда и приучала бы себя
игнорировать.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] источники — `_dist`-таблицы обоих видов брака;
- [ ] прогон заменяет партиции только живых дней источника и не трогает
истекшие;
- [ ] задача `dq_errors_daily` в `homework_check` зеленая — в том числе на
чистом стенде, где брака нет;
- [ ] задача витрины добавлена в `dm_transform`.
+114
View File
@@ -0,0 +1,114 @@
# Задание 1: `dm.events_enriched_v`
**Чему учит:** слой — это контракт, а не копия данных. Витрина, которая не
меняет зерно источника, остается представлением, и копии событий ради
других имен колонок не заводит ни один боевой склад.
Это разминка. Строк она не хранит, в конвейер не встает, ключа шардирования
у нее нет. Задача одна: собрать плоское событие для BI и не поддаться
соблазну разложить его на диск.
## Роль витрины
Договор события в DDS широкий — 44 колонки, почти весь формат выгрузки:
часть из них технические координаты источника, часть — параллельные массивы
товаров. Аналитику в Superset нужен плоский набор деловых полей и пара
производных, которые иначе каждый пишет заново в каждом запросе.
Витрина отвечает на вопрос «что вообще происходило»: с нее начинают, когда
надо посмотреть на события глазами человека, а не разбирать широкую строку
источника.
Источником для витрин следующих заданий она не служит. Витрина слоя DM
читает публичный договор DDS — так устроены и стендовые витрины, и потому
задания независимы: соседнюю домашку можно делать первой, а можно не делать
вовсе.
## Зерно и форма
Зерно — **событие**: одна строка витрины равна одной строке `dds.event_v`.
Зерно источника сохранено, значит форма — обычное представление
`CREATE VIEW dm.events_enriched_v`, без физической таблицы, без донора и
без задачи в `dm_transform`. Пересобирать нечего.
Источник — `dds.event_v`, и только он.
## Состав колонок
Явный список обязателен: договор не должен расширяться сам, когда в
источник добавят колонку.
**Время и посетитель:** `event_date`, `event_time`, `event_hour`,
`client_id`, `event_id`.
**Действие:** `event_type` (`pageview`, `add_to_cart`, `purchase`), `url`,
`page_path`, `referer`, `referer_host`, `title`.
**Атрибуция:** `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`,
`utm_term`.
**Паспорт визита:** `device_category`, `browser`, `operating_system`,
`region_country`, `region_city`.
**Торговый блок, массивами как в источнике:** `product_id`, `product_name`,
`product_category`, `product_price`, `product_quantity`,
`product_event_type`, `purchase_id`, `purchase_revenue`, `purchase_coupon`.
Три колонки в источнике не лежат и считаются здесь:
| Колонка | Как считается | Зачем |
|---|---|---|
| `event_hour` | `toHour(event_time)` | суточный профиль спрашивают в каждом отчете |
| `page_path` | путь `url` без домена и параметров | группировка по странице без разбора строки в каждом запросе |
| `referer_host` | домен `referer` | «откуда пришли» одним значением, а не полным адресом |
Служебных `_load_id` и `_load_ts` у витрины нет, и это не забывчивость. Их
нет и у `dds.event_v`: событие в DDS не грузят, а читают из ODS, поэтому
координат собственной загрузки у него не бывает. Служебная колонка называет
запуск, который положил строку на диск, — здесь на диск никто ничего не
кладет.
## Ловушка задания
Массивы `product_*` тянет развернуть через `ARRAY JOIN` — так плоская
таблица становится по-настоящему плоской. Разворачивать нельзя, и вреда
здесь два.
Очевидный: у события с тремя позициями получилось бы три строки, зерно
витрины перестало бы совпадать с зерном источника, а вместе с ним развалилось
бы и правило формы — такую витрину уже пришлось бы материализовать.
Неочевидный и куда более разрушительный: `ARRAY JOIN` — соединение
внутреннее, и строку с пустым массивом он выбрасывает. Торговый блок пуст у
всех событий, кроме корзин и покупок, то есть у подавляющего большинства.
Так «плоская таблица событий» превращается в таблицу товарных позиций и
теряет девятнадцать строк из двадцати — а по имени и по составу колонок она
по-прежнему выглядит таблицей событий. Ошибка не падает и ничего не
подсвечивает.
Массивы едут в договор как есть; разворачивает их тот, кому нужна позиция
заказа, — и делает это уже в своей витрине, с другим зерном и другим именем.
## Как проверить себя
Деловой ключ — `event_id` внутри дня. Собери прямой ответ из `dds.event_v`
за тот же день, соедини с витриной полным внешним соединением и посчитай
строки, где ключа нет с одной из сторон или где различаются значения
деловых колонок. Ноль — зеленый.
Отдельно проверяются три производные колонки: `event_hour`, `page_path` и
`referer_host` должны совпасть с тем же выражением, посчитанным по
`dds.event_v` на лету.
Если строк в витрине оказалось в разы меньше, чем в источнике, а остались в
ней одни корзины и покупки — это развернутые массивы: `ARRAY JOIN` выбросил
все события с пустым торговым блоком.
## Приемка
- [ ] `dm.events_enriched_v` создано, физических таблиц под ним нет;
- [ ] список колонок в представлении явный;
- [ ] число строк и набор `event_id` совпадают с `dds.event_v` за каждый
прожитый день;
- [ ] задача `events_enriched` в `homework_check` зеленая;
- [ ] задачи в `dm_transform` **нет** — и не должно быть.
+121
View File
@@ -0,0 +1,121 @@
# Задание 3: `dm.session_overview`
**Чему учит:** агрегат хранит слагаемые, а не готовые средние и доли.
Среднее по строкам витрины не складывается никогда, а уникальных нельзя
складывать даже тогда, когда на сегодняшних данных сумма сходится.
## Роль витрины
Дневной портрет визитов в разрезе устройств: сколько было сессий, какие они
были длинные, сколько страниц смотрели и как часто доходили до покупки.
Вопрос витрины — «как люди ходят по магазину с телефона и с настольного
компьютера, и чем эти визиты отличаются».
Сессия на стенде — расчетная сущность, ее уже нарезал слой DDS. Собирать
визиты заново из событий не нужно и нельзя: это была бы вторая нарезка,
которая тихо разойдется с первой.
Витрина с таким именем была и на прошлом стенде, но зерно у нее сменилось,
и сменилось не по вкусу. Там визитов как сущности не было, и витрина
нарезала их сама, строкой на визит. Здесь визит — сущность слоя DDS, и
витрина, повторяющая его зерно, оказалась бы обычным представлением по
[правилу формы](../architecture/dm.md): копией сессий с другими именами
колонок. Чтобы задание осталось про дневной агрегат, витрина поднялась на
зерно выше — до дня и среза.
## Зерно и форма
Зерно — **день сессии × категория устройства**. Источник —
`dds.session_v`, у него зерно визита, значит зерно сменилось и витрина
материализуется: `dm.session_overview_rep` / `_dist`, донор `_stage_rep` /
`_stage_dist`, публичное `dm.session_overview_v`. Ключ партиции — день.
Категория устройства взята из сессии как есть: у куки она постоянна, и слой
DDS уже перевел код источника в слово. Словарь расшифровки шире, чем мир
стенда: слов в нем четыре, а встречаются три — `desktop`, `mobile` и
`tablet`. Строку под `tv` заводить незачем, она возникнет сама, если такие
визиты появятся.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `report_date` | `Date` | день сессии |
| `device_category` | `LowCardinality(String)` | категория устройства |
| `sessions` | `UInt64` | число визитов |
| `visitors` | `UInt64` | число разных `client_id` |
| `bounce_sessions` | `UInt64` | визиты ровно из одного события |
| `purchase_sessions` | `UInt64` | визиты, в которых была хотя бы одна покупка |
| `duration_seconds_total` | `UInt64` | суммарная длительность визитов |
| `events_total` | `UInt64` | событий во всех визитах |
| `pageviews` | `UInt64` | просмотров страниц |
| `cart_adds` | `UInt64` | добавлений в корзину |
| `purchases` | `UInt64` | покупок |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
Средней длительности и доли отказов в витрине нет намеренно. Хранить
`avg_duration_seconds` было бы удобнее ровно один раз — пока смотрят одну
строку. Тот, кто захочет среднее по дню целиком, сложит средние по трем
устройствам и получит неправду: средние не складываются, они взвешиваются.
Пара «`duration_seconds_total` и `sessions`» дает верный ответ на любом
срезе, а деление стоит потребителю один вызов. То же и с долей отказов:
храним `bounce_sessions` и `sessions`, долю считает тот, кто спрашивает.
С `visitors` история тоньше, и в ней главная ловушка задания. Складывать
уникальных по строкам нельзя: один и тот же посетитель, попавший в две
строки, будет посчитан дважды. Проверь на стенде — и увидишь, что сумма
`visitors` по устройствам за день **сходится** с `dm.daily_traffic_v`.
Это не отменяет правила, а показывает, откуда берется совпадение: в мире
стенда устройство приписано куке на всю жизнь ([словарь
понятий](../../CONTEXT.md), «Паспорт куки»), поэтому одна кука лежит ровно
в одной строке дня, и пересекаться строкам не в чем.
Совпадение это — свойство мира, а не свойство витрины. Появятся визиты
одной куки с двух устройств — сумма поедет, и ни одна проверка об этом не
скажет: отчет просто начнет тихо завышать аудиторию. Отсюда правило,
которое стоит унести с задания: складывать уникальных нельзя даже там, где
сегодня получается. Число посетителей дня спрашивают у
`dm.daily_traffic_v`, где оно посчитано на своем зерне.
## Ритм пересборки
Источник — сессии: день замерзает, как только прожит. Объем прогона тот же,
что в задании 2, — хвост от первого несобранного прожитого дня; механика та
же: донор и замена дневных партиций.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Здесь у измерения всего три встречающихся значения, и распределены
они неровно: планшеты — тонкая полоса, а телефоны и настольные машины держат
основную массу. Прежде чем брать хеш от одного `device_category`, посчитай,
сколько строк ляжет на каждый шард, и назови, чем тебя устраивает результат.
## Как проверить себя
Деловой ключ — `device_category` внутри дня. Собери прямой агрегат из
`dds.session_v` за день, соедини с `dm.session_overview_v` полным внешним
соединением и посчитай строки, где ключа нет с одной из сторон или где
расходится любой счетчик. Ноль — зеленый.
Две проверки покрупнее ловят грубую ошибку сразу:
- сумма `sessions` за день равна числу строк `dds.session_v` этого дня:
ни один визит не потерян и ни один не задвоен;
- сумма `events_total` за день равна сумме `events_total` по сессиям того
же дня.
Если суммы событий сходятся, а сумма сессий больше числа визитов — почти
наверняка витрина собрана из событий, а не из сессий.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] `dm.session_overview_v` перечисляет колонки явно;
- [ ] источник витрины — `dds.session_v`, своей нарезки визитов в ней нет;
- [ ] средних и долей в физической таблице нет;
- [ ] задача `session_overview` в `homework_check` зеленая;
- [ ] задача витрины добавлена в `dm_transform`.
+87
View File
@@ -0,0 +1,87 @@
# Задание 6 (необязательное): свой дашборд в Superset
**Чему учит:** довести свои витрины до потребителя — завести в Superset
датасеты поверх публичных представлений, подобрать графики под вопросы и
собрать из них один связный экран.
Эта лаба идет после заданий 1–5 и опирается на их результат. Ее можно
пропустить: основной предмет стенда — ClickHouse, а здесь вся работа — в
интерфейсе BI.
## Роль дашборда
Собери обзор трафика и поведения посетителей. Он должен отвечать на три
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
каналы дали результат, были ли ошибки приема данных.
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
собери из пяти витрин, которые ты построил в заданиях. Копировать раскладку
стендового дашборда не нужно: выбери форму, в которой ответы читаются без
пояснений рядом с экраном.
## Свои объекты — отдельно
Не добавляй графики в стендовый дашборд и не правь его датасеты. При
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
таких объектов описан в [доке Superset](../architecture/superset.md), «Импорт
при каждой инициализации».
Заведи отдельные датасеты и графики, собери из них свой дашборд. Назови его,
например, «Мои витрины DM». Это то же правило, по которому DDL домашек лежит
в `sql/ddl/70-labs.sql`: стенд привозит свои объекты, менти создает свои.
## Датасеты
Заведи в подключении `ClickHouse` пять физических датасетов схемы `dm`:
- `events_enriched_v`;
- `top_pages_daily_v`;
- `session_overview_v`;
- `dq_errors_daily_v`;
- `utm_effectiveness_v`.
## Состав дашборда
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
графика выбери сам. На дашборде покажи следующее:
| Витрина | Что показать |
|---|---|
| `events_enriched_v` | число событий по часу и типу события |
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
Для долей используй слагаемые из витрин. Например, доля отказов считается
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
долям для такого графика не подходит — урок задания 3 остается в силе.
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
## Как проверить себя
Эта лаба проверяется глазами. У расположения панелей и понятных подписей нет
счетного инварианта, поэтому задачи для нее в `homework_check` нет.
Открой список дашбордов и проверь:
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
- твой дашборд открывается без ошибок и содержит график от каждого из пяти
датасетов;
- подписи, единицы измерения и легенды читаются без обращения к SQL;
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
серверную;
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
Последний проход сделай как читатель: найди на экране самый заметный канал,
страницу и устройство, затем проверь, понятно ли из подписей, что именно
сравнивается. Если ответ приходится угадывать, поправь график или его название.
## Приемка
- [ ] пять датасетов читают публичные представления витрин заданий 1–5;
- [ ] создан отдельный дашборд, стендовые объекты не изменены;
- [ ] каждая витрина представлена хотя бы одним графиком;
- [ ] дашборд проверен глазами по разделу выше.
+114
View File
@@ -0,0 +1,114 @@
# Задание 2: `dm.top_pages_daily`
**Чему учит:** зерно сменилось — значит витрина материализуется, живет
дневными партициями и встает в конвейер. Первая настоящая витрина: пять
объектов, донор, замена партиции, задача в `dm_transform`.
## Роль витрины
Популярность страниц по дням: сколько раз страницу открыли, сколько разных
посетителей ее видели и что на ней делали кроме просмотра.
Торговое событие садится на ту страницу, где случилось: корзина — на
карточку товара, покупка — на страницу подтверждения заказа. Поэтому у
карточки товара рядом стоят просмотры и корзины, и их отношение —
конверсия карточки — главный вопрос к этой витрине: какие товары кладут в
корзину, едва открыв, а какие смотрят и уходят.
Воронки «просмотр → корзина → покупка» здесь нет и быть не может: корзина и
покупка живут на разных страницах, и связать их шаги в одну цепочку на
зерне страницы не из чего. Путь одного человека собирают из событий или из
сессий, а витрина страниц отвечает про страницу.
## Зерно и форма
Зерно — **день события × страница**. Источник — события, у него зерно
события, значит зерно сменилось и витрина материализуется:
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
публичное `dm.top_pages_daily_v`.
Страница — это `page_path`, путь `url` без домена и параметров запроса;
считается он здесь же, из колонки `url` источника. Полный адрес в зерно не
берется: с параметрами запроса одна и та же страница рассыпалась бы на
сотни строк, и витрина перестала бы отвечать на свой вопрос.
Ключ партиции — день: он же единица пересборки.
Источник — `dds.event_v`, публичный договор события. Витрина задания 1
источником не служит: витрина слоя DM читает договор DDS, как и стендовые
витрины, и не зависит от того, выполнена ли соседняя домашка.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `report_date` | `Date` | день события |
| `page_path` | `LowCardinality(String)` | путь страницы |
| `pageviews` | `UInt64` | число событий `pageview` на странице |
| `visitors` | `UInt64` | число разных `client_id` на странице |
| `cart_adds` | `UInt64` | число событий `add_to_cart` на странице |
| `purchases` | `UInt64` | число событий `purchase` на странице |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
Счетчики условные: строку дают все три типа событий, а различает их
`countIf`. Отфильтровать источник по `event_type = 'pageview'`, как
подсказывает имя витрины, — значит получить витрину из одних просмотров:
страницы останутся на месте, а `cart_adds` и `purchases` у всех строк
обнулятся. Ошибка тихая: витрина выглядит собранной, и главный ее вопрос —
конверсия карточки — молча отвечает «ноль везде».
## Ритм пересборки
Источник — события: день замерзает сразу, как только прожит. Значит
пересобирать нужно только то, что еще не собрано: хвост дней, которых в
витрине нет, — от первого несобранного прожитого дня до последнего
прожитого. Готовые дни трогать незачем, а вот дыру, оставленную частичным
отказом, объем прогона обязан подобрать: ищи не последний собранный день, а
первый несобранный.
Механика — сборка отрезка в донор одной вставкой и замена дневных партиций
по одной, как у `revenue_daily`.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Подумай, чем эта витрина отличается от `revenue_daily`: там кроме
дня было одно измерение из шести близких по объему значений, здесь —
страницы, которых много и которые по популярности очень неровные.
## Как проверить себя
Деловой ключ — `page_path` внутри дня. Собери прямой агрегат из
`dds.event_v` за день, соедини с `dm.top_pages_daily_v` полным внешним
соединением по ключу и посчитай строки, где ключа нет с одной из сторон или
где расходится любой из четырех счетчиков. Ноль — зеленый.
Две проверки попроще, ими удобно ловить грубую ошибку до полного
соединения:
- сумма `pageviews` за день равна числу событий `pageview` этого дня в
`dds.event_v`;
- сумма `purchases` за день равна числу событий `purchase` этого дня.
Проверки ловят разное. Фильтр `WHERE event_type = 'pageview'` первая из них
пропускает: отбор строк по типу не меняет счет событий того же типа.
Расхождение покажет только вторая — вместе с `cart_adds` обнулятся и
`purchases`. Проверку выбирают по тому, умеет ли она покраснеть на твоей
ошибке, а не по тому, как быстро она пишется: [проверка, которая не умеет
краснеть, бесполезна](../architecture/testing.md).
`visitors` складывать по строкам нельзя: один посетитель ходит по разным
страницам и в сумме посчитается несколько раз. Число посетителей дня
спрашивают у `dm.daily_traffic_v`, а не у этой витрины.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] `dm.top_pages_daily_v` перечисляет колонки явно, включая `_load_id` и
`_load_ts`;
- [ ] объем прогона выводится из состояния витрины, а не задается руками;
- [ ] задача `top_pages_daily` в `homework_check` зеленая;
- [ ] задача витрины добавлена в `dm_transform`, повторный прогон конвейера
результат не меняет.
+150
View File
@@ -0,0 +1,150 @@
# Задание 5: `dm.utm_effectiveness`
**Чему учит:** приставка `declared_` в имени колонки — предупреждение, а не
украшение. Она говорит: это заявка клиента, а не деньги отчетности. Урок
здесь дает конфликт: суммы не сойдутся, и раскапывать почему предстоит
самому.
Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по
бэкенду**. Эта витрина — единственное разрешенное исключение: атрибуции по
UTM у бэкенда нет, метки живут только в кликстриме. Раз считать иначе
нечем, клиентская сумма берется, но называется своим именем.
## Роль витрины
Эффективность рекламных каналов по дням: сколько трафика привел канал,
сколько корзин и покупок в нем случилось и на какую сумму эти покупки, по
словам клиента.
## Атрибуция: по событию, а не по визиту
Метки едут на каждом событии, и витрина группирует события по их
собственным меткам. Модели «первое касание» или «последнее касание» здесь
нет: их построение — отдельная тема, а это витрина трафика по меткам.
`dds.session_v` при этом несет UTM первого события визита, и ответ «какой
канал привел» из сессии и из этой витрины будет разным. Это не
ошибка ни одной из сторон — это два разных вопроса, и в отчете их надо
различать.
## Прямой трафик из витрины не выбрасывается
Соблазн отфильтровать строки без меток велик: витрина же про рекламу.
Отфильтровать нельзя по двум причинам. Первая деловая: прямой трафик —
обычно самый крупный «канал» магазина, и отчет, где его нет, врет о доле
рекламы. Вторая техническая: как только часть событий выпала, итог витрины
перестал сходиться с итогом источника, и проверить сборку стало нечем.
Пустые метки остаются пустыми строками, как в источнике. Своего слова для
«прямого захода» витрина не изобретает: чем меньше выдуманных значений, тем
меньше расшифровывать читателю.
## Зерно и форма
Зерно — **день события × `utm_source` × `utm_medium` × `utm_campaign`**.
Зерно источника — событие, значит витрина материализуется:
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
публичное `dm.utm_effectiveness_v`. Ключ партиции — день.
Источник — `dds.event_v`, публичный договор события: витрина слоя DM читает
договор DDS, а не соседнюю витрину.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `report_date` | `Date` | день события |
| `utm_source` | `LowCardinality(String)` | источник кампании |
| `utm_medium` | `LowCardinality(String)` | канал кампании |
| `utm_campaign` | `String` | имя кампании |
| `visitors` | `UInt64` | число разных `client_id` |
| `pageviews` | `UInt64` | просмотров страниц |
| `add_to_carts` | `UInt64` | добавлений в корзину |
| `purchases` | `UInt64` | покупок |
| `declared_revenue` | `Decimal(18, 2)` | выручка покупок глазами клиента |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
`declared_revenue` складывает `purchase_revenue[1]` событий `purchase`.
Индекс не описка: блок `purchase*` едет параллельными массивами, и у события
покупки в них ровно один элемент — контракт источника обещает один номер
заказа и одну объявленную сумму на событие. Тем же индексом берет свою
сторону стендовая сверка `purchase_vs_orders`.
Приводить тип не нужно: в договоре `purchase_revenue` уже
`Array(Decimal(18, 2))`. `Float64` эта сумма была только в выгрузке
источника, и ODS перевел ее в точные деньги на приеме — сложение Decimal
точное, и где стоит `CAST`, до или после `sum`, на результат не влияет.
Витрина просто хранит то, что сложила, в `Decimal(18, 2)`.
`utm_campaign` оставлена обычной строкой: имен кампаний со временем
становится много, и словарь `LowCardinality` на растущем множестве
перестает окупаться.
## Ритм пересборки
Источник — события: день замерзает сразу. Объем прогона и механика те же,
что в задании 2: хвост несобранных дней, донор, замена дневных партиций.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Зерно здесь составное, из трех колонок, и решение «взять хеш от
одной из них» надо защищать отдельно: назови, какая доля строк придется на
самое частое значение выбранной колонки.
## Конфликт, ради которого витрина и написана
Собери за один и тот же день две суммы: `sum(declared_revenue)` из своей
витрины и `sum(revenue)` из `dm.revenue_daily_v`. Они не сойдутся, и это
верное поведение обеих витрин.
Базы у двух сумм при этом сравнимые: `revenue_daily` складывает цену позиций
на количество и ни скидки, ни доставки в себя не берет — ровно то, что видит
клиент. Значит все расхождение до копейки объясняется тем, что случилось с
заказом, а не тем, как считали деньги. Ищи ответы среди таких причин:
- отмененный заказ из кликстрима никуда не девается: событие покупки
случилось, а `revenue_daily` считает только оплаченные;
- событие покупки может потеряться по дороге, а заказ при этом доехать —
и наоборот, событие приехать дважды;
- клиент назвал сумму, отличную от серверной суммы позиций: генератор
делает это намеренно у одного-двух заказов из ста.
Как эти же расхождения выглядят на зерне заказа, показывает стендовая
витрина `dm.purchase_vs_orders_v` с ее классами расхождений: это готовый
ответ на вопрос «а сколько именно и из-за чего». Запиши свой вывод —
в двух-трех фразах, своими словами.
Проверять сходимость этих двух сумм не нужно и нельзя: они и не должны
сходиться.
## Как проверить себя
Деловой ключ — тройка `utm_source`, `utm_medium`, `utm_campaign` внутри
дня. Собери прямой агрегат из `dds.event_v` за день, соедини с
`dm.utm_effectiveness_v` полным внешним соединением и посчитай строки, где
ключа нет с одной из сторон или где расходится любая из пяти величин. Ноль —
зеленый.
Две проверки покрупнее ловят самые частые ошибки:
- сумма `pageviews`, `add_to_carts` и `purchases` за день равна числу
событий соответствующего типа в `dds.event_v` за тот же день;
- сумма `declared_revenue` за день равна сумме `purchase_revenue[1]` по всем
событиям `purchase` этого дня.
Обе не сойдутся, если прямой трафик отфильтрован, и разойдутся ровно на его
долю — по этому следу отфильтрованные строки и находят. `visitors`, как и в
задании 3, по строкам не складывается: один посетитель приходит по разным
меткам.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] строки без UTM-меток в витрине остались;
- [ ] `declared_revenue` хранится в `Decimal(18, 2)`;
- [ ] расхождение с `dm.revenue_daily_v` объяснено письменно;
- [ ] задача `utm_effectiveness` в `homework_check` зеленая;
- [ ] задача витрины добавлена в `dm_transform`.
+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/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()` должен входить в ТЗ каждого вычитания отдельной строкой.
+39 -24
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`, без универсального
слоя кодеков. слоя кодеков.
@@ -264,7 +265,7 @@ 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% |
@@ -273,8 +274,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира; ориентир ~10%. Точные доли зафиксированы замером на каноническом мире
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и (28 дней, 2026-08-30, #194) — запись в «Что проверено»
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
только по дням с закрытым окном (раздел 8). только по дням с закрытым окном (раздел 8).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) — Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
@@ -285,7 +287,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM); под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
слово `declared` в имени — сигнал «это заявка клиента, не деньги слово `declared` в имени — сигнал «это заявка клиента, не деньги
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
раскопает почему (Float64 против Decimal, промокод, доставка, отмены). раскопает почему (состав позиций, промокод, доставка, отмены).
## 5. Анонимность и склейка идентичностей ## 5. Анонимность и склейка идентичностей
@@ -511,10 +513,16 @@ Kafka день переигрывается генератором заново:
`dds.identity_map_v`: опыт на ClickHouse 26.3 показал, что публичные `_v` `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 получает три новых сюжета: выручка по дням и категориям,
таблица сверки с классами расхождений, пара посетители/известные. таблица сверки с классами расхождений, пара посетители/известные.
@@ -525,10 +533,11 @@ Kafka день переигрывается генератором заново:
## 8. Эталонный мир и опись ## 8. Эталонный мир и опись
В git хранится только опись эталонного мира; сам снимок (14 модельных В git хранится только опись эталонного мира; сам снимок генерируется на
дней) генерируется на месте — при `make up` и при проверках (решение месте — при `make up` и при проверках (решение развилки «Производительность»
развилки «Производительность» карты #26, подробности — карты #26, подробности — [спека генератора](2026-08-01-generator.md),
[спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней
снято на этапе 7. Опись несёт
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
@@ -548,8 +557,8 @@ Kafka день переигрывается генератором заново:
названного читателя в опись не кладётся названного читателя в опись не кладётся
([что хранит опись](../architecture/orders/inventory.md)). ([что хранит опись](../architecture/orders/inventory.md)).
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
при пересборке. [карты проверок](../architecture/testing.md).
## 9. Оценка объёма исполнения ## 9. Оценка объёма исполнения
@@ -611,8 +620,8 @@ v2 стартует пустым, поэтому объём ниже — это
выручка, сверка 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. Этап идёт не подряд, см. ниже.
@@ -649,9 +658,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 между нодами, настоящее хранилище и ротация секретов, внешние учётные
File diff suppressed because it is too large. Load diff
+293
View File
@@ -0,0 +1,293 @@
# Программа учебных лаб
Эта спека собирает программу лаб из
[инвентаризации сюжетов](../research/2026-08-31-labs-inventory.md), выполненной
в тикете #201. Тикет спеки — #202, карта — #182.
Статус: спека принята владельцем; судьба идей-кандидатов решена здесь поименно.
## 1. Кому и зачем
Менти уже прошел большой теоретический курс ClickHouse объемом около 60 часов.
Он знает устройство системы в теории, но почти ничего не делал своими руками.
Поэтому в программе нет вводного блока с теорией. Здесь теория становится
ремеслом: менти руками осваивает слои хранилища, DQ, оркестрацию и
идемпотентность.
Обещание программы: менти проходит конвейер стенда автором, а не читателем.
## 2. Устройство программы: основная программа и факультативы
У частей программы два статуса, третьего нет.
**Основная программа** — обязательная часть. Менти проходит ее целиком и по
порядку, потому что каждая следующая ступень опирается на предыдущую. От
ступени к ступени растет самостоятельность: сначала менти получает готовые ТЗ с
готовыми сторожами (DQ-проверками), а затем строит сторожей сам.
**Факультативы** — необязательные лабы. Они собраны в тематические разделы,
чтобы менти мог одним взглядом выбрать интересное. Для каждого раздела указано,
после какой ступени основной программы он открывается.
В программе используются четыре формы заданий:
- *построить свое по ТЗ* — рядом есть образец, но готового ответа нет;
- *вычитание* — SQL готовой трансформации удаляется и пишется заново, а даги
остаются как несущая инфраструктура;
- *собери рядом* — менти строит свою копию результата рядом с нетронутым
стендовым и сверяет их построчно;
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а
менти ищет причину и объясняет ее.
## 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. Разбери сырые события и сверь результат со стендом
Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно
сверяет результат с нетронутым стендовым 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 основной программы.
- **Свой дашборд.** Готовая лаба
[`docs/labs/superset-dashboard.md`](../labs/superset-dashboard.md) учит
хранить пользовательские объекты отдельно от стендовых.
- **Почему дашборд не видит объект на второй ноде.** Забытый `ON CLUSTER` может
остаться незаметным до тех пор, пока дашборд не обратится ко второй ноде.
### Инженерия стенда
Открывается после ступени 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:
+6 -25
View File
@@ -13,31 +13,11 @@
## Как это работает ## Как это работает
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
> выверен: пишет его тот, кто держит устройство генератора в голове, а это дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48. а день-функция проживает один день без накопленного состояния. Код этой
цепочки, ее схема и запуск D0 собраны в
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве [путеводителе](../docs/architecture/generator/README.md).
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
история для него не нужна и на него не влияет.
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
значит сдвинуть все броски после него, а с ними и весь мир: события того же
дня станут другими, счётчики канонического мира разойдутся с описью, и
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
`plan.cohort` брошены последними, а личность человека приписана уже за ними:
куки, пары и паспорта остались теми же до байта.
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
распределения numpy расходятся между версиями и архитектурами, а обещано
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
`rng.choice` с вероятностями.
## Что где лежит ## Что где лежит
@@ -104,6 +84,7 @@ D0 живёт предыстория, поэтому любой день соб
- `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` — пересобрать опись мира.
+164 -11
View File
@@ -53,6 +53,7 @@
""" """
from dataclasses import dataclass from dataclasses import dataclass
from enum import IntEnum
from typing import Any from typing import Any
import numpy as np import numpy as np
@@ -160,6 +161,14 @@ class Purchases:
return self.person_id.size return self.person_id.size
class EventOutcome(IntEnum):
"""Что случилось с маячком purchase после создания заказа."""
DELIVERED = 0
LOST = 1
DUPLICATED = 2
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _Events: class _Events:
"""Строки одного вида торговых событий, готовые встать в поток. """Строки одного вида торговых событий, готовые встать в поток.
@@ -177,9 +186,18 @@ class _Events:
basket: NDArray[np.int64] basket: NDArray[np.int64]
# Что торговая половина отдаёт дню: поток целиком — колонки, страницы и @dataclass(frozen=True, slots=True)
# товары — и покупки дня вторым выходом. class Woven:
Woven = tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], Purchases] """Что торговая половина отдает дню после вплетения событий."""
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
purchases: Purchases
event_outcome: NDArray[np.int8]
# Для каждой покупки: назначен ли её заказ планом.
assigned_order: NDArray[np.bool_]
# День без единой корзины: покупок в нём нет, а форма у рядов есть. # День без единой корзины: покупок в нём нет, а форма у рядов есть.
_NO_PURCHASES = Purchases( _NO_PURCHASES = Purchases(
@@ -191,6 +209,8 @@ _NO_PURCHASES = Purchases(
revenue=np.empty(0, dtype=np.int64), revenue=np.empty(0, dtype=np.int64),
moment=np.empty(0, dtype="datetime64[s]"), moment=np.empty(0, dtype="datetime64[s]"),
) )
_NO_EVENT_OUTCOMES = np.empty(0, dtype=np.int8)
_NO_ASSIGNED_ORDERS = np.empty(0, dtype=np.bool_)
def weave( def weave(
@@ -200,6 +220,7 @@ def weave(
page: NDArray[np.uint8], page: NDArray[np.uint8],
product: NDArray[np.int64], product: NDArray[np.int64],
person: NDArray[np.uint64], person: NDArray[np.uint64],
assigned_order: NDArray[np.bool_],
) -> Woven: ) -> Woven:
"""Вплетает торговые события в поток и отдаёт поток и покупки дня. """Вплетает торговые события в поток и отдаёт поток и покупки дня.
@@ -207,14 +228,21 @@ def weave(
события встают между ними, и поток пересобирается одним порядком. Второй события встают между ними, и поток пересобирается одним порядком. Второй
выход — покупки дня: то же самое, чем они уехали в события. выход — покупки дня: то же самое, чем они уехали в события.
`person` — человек за кукой каждой строки, выровненный по входящему `person` и `assigned_order` выровнены по входящему потоку. Оба
потоку: личность приходит от плана состава, а не выводится из трекера признака приходят из плана состава: личность дает заказу `user_id`,
(docs/architecture/orders/identity.md). а назначение защищает мост склейки от потери purchase.
""" """
rng = day_stream(seed, day, Component.COMMERCE) rng = day_stream(seed, day, Component.COMMERCE)
baskets = _baskets(rng, page, product, columns["VisitID"]) baskets = _baskets(rng, page, product, columns["VisitID"])
if not len(baskets): if not len(baskets):
return columns, page, product, _NO_PURCHASES return Woven(
columns=columns,
page=page,
product=product,
purchases=_NO_PURCHASES,
event_outcome=_NO_EVENT_OUTCOMES,
assigned_order=_NO_ASSIGNED_ORDERS,
)
goods = catalog.catalog() goods = catalog.catalog()
draws = _draws(rng, baskets) draws = _draws(rng, baskets)
@@ -225,8 +253,20 @@ def weave(
_order_events(columns, baskets, goods, draws, revenue, coupons), _order_events(columns, baskets, goods, draws, revenue, coupons),
) )
columns, page, product, basket = _stream(rng, columns, page, product, events) columns, page, product, basket = _stream(rng, columns, page, product, events)
purchases = _purchases(columns, basket, baskets, draws, revenue, coupons, person) purchases, assigned = _purchases(
return columns, page, product, purchases columns, basket, baskets, draws, revenue, coupons, person, assigned_order
)
columns, page, product, event_outcome = _corrupt_events(
seed, day, columns, page, product, purchases, assigned
)
return Woven(
columns=columns,
page=page,
product=product,
purchases=purchases,
event_outcome=event_outcome,
assigned_order=assigned,
)
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws: def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
@@ -653,7 +693,8 @@ def _purchases(
revenue: NDArray[np.int64], revenue: NDArray[np.int64],
coupons: NDArray[np.object_], coupons: NDArray[np.object_],
person: NDArray[np.uint64], person: NDArray[np.uint64],
) -> Purchases: assigned_order: NDArray[np.bool_],
) -> tuple[Purchases, NDArray[np.bool_]]:
"""Покупки дня — то же, чем они уехали в события, только структурой. """Покупки дня — то же, чем они уехали в события, только структурой.
Порядок здесь — порядок строк потока, он же порядок номеров заказа: Порядок здесь — порядок строк потока, он же порядок номеров заказа:
@@ -664,7 +705,7 @@ def _purchases(
here = np.flatnonzero(rows["EventType"] == PURCHASE) here = np.flatnonzero(rows["EventType"] == PURCHASE)
mine = basket[here] mine = basket[here]
bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()] bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()]
return Purchases( purchases = Purchases(
order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()), order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()),
person_id=person[baskets.confirmation[mine]], person_id=person[baskets.confirmation[mine]],
product=tuple(baskets.product[group] for group in bought), product=tuple(baskets.product[group] for group in bought),
@@ -673,6 +714,118 @@ def _purchases(
revenue=revenue[mine], revenue=revenue[mine],
moment=rows["UTCEventTime"][here], moment=rows["UTCEventTime"][here],
) )
return purchases, assigned_order[baskets.confirmation[mine]]
def _corrupt_events(
seed: int,
day: int,
rows: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
purchases: Purchases,
assigned: NDArray[np.bool_],
) -> tuple[
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int8]
]:
"""Теряет purchase или повторяет пару подтверждения.
Заказы уже собраны, а номера покупок уже вписаны в строки. Поэтому
порча меняет только событийную сторону. Все броски делаются на полную
длину покупок дня, чтобы отбор одного класса не сдвигал другой.
"""
count = len(purchases)
if not count:
return rows, page, product, _NO_EVENT_OUTCOMES
rng = day_stream(seed, day, Component.BEACONS)
lost = (rng.integers(0, 100, count) < world.LOST_PURCHASE_PERCENT) & ~assigned
duplicate_draw = rng.integers(0, 100, count) < world.DUPLICATE_PURCHASE_PERCENT
delay = rng.integers(*world.DUPLICATE_DELAY_SECONDS, count)
watch = ids.unique_apart_from(rng, 2 * count, rows["WatchID"]).reshape(count, 2)
purchase_rows = np.flatnonzero(rows["EventType"] == PURCHASE)
confirmation_rows = _confirmation_rows(rows, page, purchase_rows)
eligible = _duplicate_eligibility(rows, purchase_rows, delay)
duplicated = ~lost & duplicate_draw & eligible
outcome = np.full(count, EventOutcome.DELIVERED, dtype=np.int8)
outcome[lost] = EventOutcome.LOST
outcome[duplicated] = EventOutcome.DUPLICATED
keep = np.ones(page.size, dtype=bool)
keep[purchase_rows[lost]] = False
sources = np.column_stack(
(confirmation_rows[duplicated], purchase_rows[duplicated])
)
selected = np.flatnonzero(duplicated)
if selected.size:
copies = sources.ravel()
shifted = np.repeat(delay[selected], 2).astype("timedelta64[s]")
additions = {name: values[copies] for name, values in rows.items()}
additions["UTCEventTime"] = additions["UTCEventTime"] + shifted
additions["WatchID"] = watch[selected].ravel()
# Первая строка пары — обновление confirmation: ее предыдущая
# страница — она же, а не checkout из первого просмотра.
additions["Referer"] = additions["URL"].copy()
rows = {
name: np.concatenate((values[keep], additions[name]))
for name, values in rows.items()
}
page = np.concatenate((page[keep], page[copies]))
product = np.concatenate((product[keep], product[copies]))
else:
rows = {name: values[keep] for name, values in rows.items()}
page = page[keep]
product = product[keep]
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
return (
{name: values[order] for name, values in rows.items()},
page[order],
product[order],
outcome,
)
def _confirmation_rows(
rows: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
purchase_rows: NDArray[np.int64],
) -> NDArray[np.int64]:
"""Находит просмотр, с которым purchase образует пару."""
confirmation = (rows["EventType"] == "pageview") & (page == Page.CONFIRMATION)
by_visit = {
int(rows["VisitID"][row]): row for row in np.flatnonzero(confirmation).tolist()
}
return np.array(
[by_visit[int(rows["VisitID"][row])] for row in purchase_rows.tolist()],
dtype=np.int64,
)
def _duplicate_eligibility(
rows: dict[str, NDArray[Any]],
purchase_rows: NDArray[np.int64],
delay: NDArray[np.int64],
) -> NDArray[np.bool_]:
"""Проверяет полночь и запас до следующего визита куки."""
moment = rows["UTCEventTime"][purchase_rows]
shifted = moment + delay.astype("timedelta64[s]")
before_midnight = shifted < _midnight(rows) + np.timedelta64(1, "D")
eligible = before_midnight.copy()
for number, row in enumerate(purchase_rows.tolist()):
later_visit = (
(rows["ClientID"] == rows["ClientID"][row])
& (rows["VisitID"] != rows["VisitID"][row])
& (rows["UTCEventTime"] > moment[number])
)
if np.any(later_visit):
next_start = rows["UTCEventTime"][later_visit].min()
eligible[number] &= next_start - moment[number] > np.timedelta64(
world.VISIT_TIMEOUT_SECONDS + int(delay[number]), "s"
)
return eligible
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64: def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
+10 -5
View File
@@ -100,6 +100,8 @@ class Day:
page: NDArray[np.uint8] page: NDArray[np.uint8]
product: NDArray[np.int64] product: NDArray[np.int64]
orders: orders.Orders orders: orders.Orders
# Событийный исход каждого заказа, в том же порядке.
purchase_outcome: NDArray[np.int8]
def __len__(self) -> int: def __len__(self) -> int:
return self.page.size return self.page.size
@@ -162,20 +164,23 @@ def stream(seed: int, day: int) -> Day:
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят # целиком: в нём же они и упорядочиваются. Вторым выходом приходят
# покупки дня — из них заказная сторона собирает заказы. # покупки дня — из них заказная сторона собирает заказы.
person = np.repeat(audience.person_id[visits.cookie], visits.pages) person = np.repeat(audience.person_id[visits.cookie], visits.pages)
columns, page, product, purchases = commerce.weave( assigned_order = np.repeat(visits.ordering, visits.pages)
woven = commerce.weave(
seed, seed,
day, day,
{name: value[order] for name, value in columns.items()}, {name: value[order] for name, value in columns.items()},
page[alive][order], page[alive][order],
product[alive][order], product[alive][order],
person[alive][order], person[alive][order],
assigned_order[alive][order],
) )
return Day( return Day(
day=day, day=day,
columns=columns, columns=woven.columns,
page=page, page=woven.page,
product=product, product=woven.product,
orders=orders.of_day(seed, day, purchases), orders=orders.of_day(seed, day, woven.purchases, woven.assigned_order),
purchase_outcome=woven.event_outcome,
) )
@@ -0,0 +1,62 @@
"""Короткий проход по цепочке генератора на каноническом дне D0."""
from collections import Counter
import numpy as np
from clickstream_generator import day, plan, seeds, world
DAY = 0
def _number(value: int) -> str:
"""Разделяет разряды числа для чтения с экрана."""
return f"{value:,}".replace(",", " ")
def render() -> str:
"""Собирает D0 и показывает его стадии без числового эталона."""
seed = seeds.CANONICAL_SEED
cohort = plan.cohort(seed, DAY)
audience = plan.audience(seed, DAY)
events = day.stream(seed, DAY)
_, rows_per_visit = np.unique(events.columns["VisitID"], return_counts=True)
event_types = Counter(str(value) for value in events.columns["EventType"])
smallest_visit = int(rows_per_visit.min())
largest_visit = int(rows_per_visit.max())
return "\n".join(
(
f"Зерно: {seed}",
"Подпотоки D0: состав мира → когорта; день → трафик, торговля, заказы",
(
f"Когорта D0: людей — {_number(cohort.people)}; "
f"кук — {_number(len(cohort.client_id))}"
),
(
f"Аудитория D0: кук — {_number(len(audience.client_id))}; "
f"когорты D-{world.RETURN_TAIL_DAYS}...D0"
),
(
f"Визиты по VisitID: групп — {_number(len(rows_per_visit))}; "
f"строк в группе — {smallest_visit}–{largest_visit}"
),
(
f"Строки событий: {_number(len(events))} "
f"(pageview — {_number(event_types['pageview'])}; "
f"add_to_cart — {_number(event_types['add_to_cart'])}; "
f"purchase — {_number(event_types['purchase'])}); "
f"заказов — {_number(len(events.orders))}"
),
)
)
def main() -> None:
"""Печатает проход по D0."""
print(render())
if __name__ == "__main__":
main()
@@ -8,8 +8,9 @@
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7. понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
Эталонный снимок в четырнадцать дней придёт на этапе 7 и станет продолжением Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
этой же описи, а не вторым файлом. у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот
же файл, а не заведет второй.
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем **Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
@@ -119,17 +120,38 @@ def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]
def _order_class_counts(today: day_module.Day) -> dict[str, int]: def _order_class_counts(today: day_module.Day) -> dict[str, int]:
"""Заказы дня по итоговому классу: отмена перевешивает дельту суммы.""" """Заказы дня по итоговому классу по приоритету спеки."""
purchase = today.columns["EventType"] == commerce.PURCHASE purchase = today.columns["EventType"] == commerce.PURCHASE
declared = today.columns["purchaseRevenue"][purchase] declared = {
counts = {"match": 0, "cancelled": 0, "amount_delta": 0} purchase_id[0]: round(revenue[0] * commerce.KOPECKS)
for purchase_id, revenue in zip(
today.columns["purchaseID"][purchase],
today.columns["purchaseRevenue"][purchase],
strict=True,
)
}
counts = {
"match": 0,
"cancelled": 0,
"lost_event": 0,
"duplicate_event": 0,
"amount_delta": 0,
}
for outcome, items_total, revenue in zip( for order_id, order_outcome, event_outcome, items_total in zip(
today.orders.outcome, today.orders.items_total, declared, strict=True today.orders.order_id,
today.orders.outcome,
today.purchase_outcome,
today.orders.items_total,
strict=True,
): ):
if outcome != orders_module.OrderOutcome.PAID: if order_outcome != orders_module.OrderOutcome.PAID:
counts["cancelled"] += 1 counts["cancelled"] += 1
elif items_total != round(revenue[0] * commerce.KOPECKS): elif event_outcome == commerce.EventOutcome.LOST:
counts["lost_event"] += 1
elif event_outcome == commerce.EventOutcome.DUPLICATED:
counts["duplicate_event"] += 1
elif items_total != declared[order_id]:
counts["amount_delta"] += 1 counts["amount_delta"] += 1
else: else:
counts["match"] += 1 counts["match"] += 1
+31 -6
View File
@@ -24,11 +24,13 @@
одной доли перебрасывала бы весь подпоток после себя одной доли перебрасывала бы весь подпоток после себя
(docs/architecture/orders/fate.md). (docs/architecture/orders/fate.md).
**Судьба заказа — исход и его моменты**: из окна изменяемости заказ выходит **Судьба заказа — исход, его моменты и задержка выгрузки**. Из окна
оплаченным или отменённым, а когда именно это случилось, сказано смещением в изменяемости заказ выходит оплаченным или отменённым, а когда именно это
секундах от рождения заказа. Момента, которого у исхода нет, нет и в данных: случилось, сказано смещением в секундах от рождения заказа. Момента, которого
его место занимает −1, а не ноль, — иначе «оплатили в секунду рождения» было у исхода нет, нет и в данных: его место занимает −1, а не ноль, — иначе
бы не отличить от «не оплатили вовсе». «оплатили в секунду рождения» было бы не отличить от «не оплатили вовсе».
Задержка говорит, сколько ранних слепков заказ пропустит; назначенный планом
приезжает сразу.
**Состояние на границе суток** — чтение готовой судьбы, а не накопление: **Состояние на границе суток** — чтение готовой судьбы, а не накопление:
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
@@ -62,6 +64,7 @@ _DELIVERY_CUMULATIVE = np.cumsum(
) )
_OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS) _OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS)
_MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS) _MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS)
_SNAPSHOT_DELAY_CUMULATIVE = np.cumsum(world.ORDER_SNAPSHOT_DELAY_WEIGHTS)
class OrderOutcome(IntEnum): class OrderOutcome(IntEnum):
@@ -103,12 +106,18 @@ class Orders:
outcome: NDArray[np.int64] outcome: NDArray[np.int64]
paid_after: NDArray[np.int64] paid_after: NDArray[np.int64]
cancelled_after: NDArray[np.int64] cancelled_after: NDArray[np.int64]
snapshot_delay: NDArray[np.int64]
def __len__(self) -> int: def __len__(self) -> int:
return self.user_id.size return self.user_id.size
def of_day(seed: int, day: int, purchases: Purchases) -> Orders: def of_day(
seed: int,
day: int,
purchases: Purchases,
assigned_order: NDArray[np.bool_],
) -> Orders:
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги.""" """Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
rng = day_stream(seed, day, Component.ORDERS) rng = day_stream(seed, day, Component.ORDERS)
delivery = _delivery(rng, len(purchases)) delivery = _delivery(rng, len(purchases))
@@ -116,6 +125,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
product, quantity, items_total = _delta(rng, purchases) product, quantity, items_total = _delta(rng, purchases)
created_at = _created_at(rng, purchases) created_at = _created_at(rng, purchases)
discount = _discount(purchases, items_total) discount = _discount(purchases, items_total)
snapshot_delay = _snapshot_delay(rng, assigned_order)
return Orders( return Orders(
day=day, day=day,
order_id=purchases.order_id, order_id=purchases.order_id,
@@ -130,6 +140,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
outcome=outcome, outcome=outcome,
paid_after=paid_after, paid_after=paid_after,
cancelled_after=cancelled_after, cancelled_after=cancelled_after,
snapshot_delay=snapshot_delay,
) )
@@ -166,6 +177,11 @@ def at_boundary(rows: Orders, day: int) -> tuple[list[str], NDArray[np.datetime6
return status.tolist(), updated return status.tolist(), updated
def arrived(rows: Orders, day: int) -> NDArray[np.bool_]:
"""Какие заказы уже попали в выгрузку к слепку дня `day`."""
return rows.day + rows.snapshot_delay <= day
def _boundary(day: int) -> np.datetime64: def _boundary(day: int) -> np.datetime64:
"""Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика. """Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика.
@@ -218,6 +234,15 @@ def _created_at(
) )
def _snapshot_delay(
rng: np.random.Generator,
assigned_order: NDArray[np.bool_],
) -> NDArray[np.int64]:
"""Сколько ранних слепков пропустит заказ; назначенный приезжает сразу."""
delay = pick(rng, _SNAPSHOT_DELAY_CUMULATIVE, assigned_order.size)
return np.where(assigned_order, 0, delay)
def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]: def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём. """Момент внутри окна: час по таблице весов и равномерная секунда в нём.
@@ -12,12 +12,12 @@
- `name` — имя источника, как в облачной выгрузке Метрики; сырой слой хранит - `name` — имя источника, как в облачной выгрузке Метрики; сырой слой хранит
его без изменений. его без изменений.
- `clickhouse_type` — тип в хранилище ровно в той записи, в какой его вернёт - `clickhouse_type` — тип колонки у источника в той записи, которой пользуется
`system.columns`: параметры входят в имя типа целиком, без сокращений ClickHouse: параметры входят в имя целиком, без сокращений
(`LowCardinality(String)`, `Array(Float64)`). Сверено 2026-08-01 — (`LowCardinality(String)`, `Array(Float64)`). Обычно ODS берет его без
по документации ClickHouse через Context7 и запросом к узлу стенда изменений. Исключение одно: денежный `purchaseRevenue` приезжает как
(26.3.17.56). Запись важна потому, что по ней человек пишет DDL: тип, Float64, а на границе хранения разбирается прямо в Decimal. Поэтому поле
сокращённый здесь, приедет в таблицу сокращённым же. описывает выгрузку, а локальный тип ODS назван в документации хранилища.
- `numpy_dtype` — чем колонка представлена внутри генератора; у массивов это - `numpy_dtype` — чем колонка представлена внутри генератора; у массивов это
тип элемента. Строки живут в `object`-массивах: numpy-строки фиксированной тип элемента. Строки живут в `object`-массивах: numpy-строки фиксированной
длины стенду ничего не дают. длины стенду ничего не дают.
@@ -371,8 +371,8 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="float64", numpy_dtype="float64",
normalized_name="purchase_revenue", normalized_name="purchase_revenue",
group=ColumnGroup.ECOMMERCE, group=ColumnGroup.ECOMMERCE,
comment="выручка заказа глазами клиента; Float64, как у Метрики —" comment="выручка заказа глазами клиента; источник объявляет Float64,"
" на этом держится урок о расхождениях с бэкендом", " ODS хранит точный Decimal",
), ),
Column( Column(
name="purchaseCurrency", name="purchaseCurrency",
@@ -27,10 +27,12 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки. собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и Имена и типы колонок описывают сторону источника. Обычно ODS принимает их без
нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное изменений. Исключение — `purchaseRevenue`: источник объявляет Float64, а ODS
имя». Это имя источника, приведённое к snake_case, а не имя атрибута в хранит деньги в Decimal. Точный локальный тип назван в документации
модели данных: слой DDS складывает свою модель и называет атрибуты по ней. хранилища. Имя в нашем стиле ждет в столбце «Нормализованное имя». Это имя
источника, приведенное к snake_case, а не имя атрибута в модели данных: слой
DDS складывает свою модель и называет атрибуты по ней.
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора; Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
контрактом. контрактом.
+3 -5
View File
@@ -7,7 +7,7 @@
│ ├── ось → номер дня: когорта этого дня │ ├── ось → номер дня: когорта этого дня
│ └── предыстория → глубина: когорта дня до D0 │ └── предыстория → глубина: когорта дня до D0
└── дни └── дни
└── номер дня → трафик, торговля, заказная сторона, опоздания └── номер дня → трафик, торговля, заказы, маячки счетчика
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7 (зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
@@ -40,10 +40,8 @@ class Component(IntEnum):
# каждого заказа читает из его собственного дня # каждого заказа читает из его собственного дня
# (docs/architecture/orders/fate.md). # (docs/architecture/orders/fate.md).
ORDERS = 2 ORDERS = 2
# Событийную сторону — потерю и дубль события — заведёт этап 6; до тех # Маячки счетчика: потеря и повторная отправка событий.
# пор позиция занята мёртвым именем прежней нарезки, и из него никто не BEACONS = 3
# бросает.
LATECOMERS = 3
class _Branch(IntEnum): class _Branch(IntEnum):
@@ -45,7 +45,7 @@ from numpy.typing import NDArray
from clickstream_generator import catalog, schema, world from clickstream_generator import catalog, schema, world
from clickstream_generator.day import Day from clickstream_generator.day import Day
from clickstream_generator.orders import Orders, at_boundary from clickstream_generator.orders import Orders, arrived, at_boundary
_ARRAY_PREFIX = "Array(" _ARRAY_PREFIX = "Array("
@@ -72,9 +72,10 @@ def events(day: Day, limit: int | None = None) -> list[bytes]:
def orders(window: Sequence[Orders], day: int) -> list[bytes]: def orders(window: Sequence[Orders], day: int) -> list[bytes]:
"""Канонические байты слепка дня `day`: по документу JSON на заказ. """Канонические байты слепка дня `day`: по документу JSON на заказ.
`window` — заказы дней окна, от раннего дня к позднему: слепок несёт их `window` — заказы дней окна, от раннего дня к позднему. Уже приехавшие
подряд, и порядок строк выходит порядком рождения заказов, он же заказы слепок несёт подряд, поэтому порядок строк остаётся порядком
возрастание `order_id`. Какие это дни, решает `orders.window`. рождения, он же возрастание `order_id`. Какие это дни, решает
`orders.window`.
Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они
станут `Decimal`, и дробь двоичного числа была бы потерей точности до станут `Decimal`, и дробь двоичного числа была бы потерей точности до
@@ -97,7 +98,8 @@ def orders(window: Sequence[Orders], day: int) -> list[bytes]:
delivery = rows.delivery.tolist() delivery = rows.delivery.tolist()
total = rows.total.tolist() total = rows.total.tolist()
for number, order_id in enumerate(rows.order_id): for number in np.flatnonzero(arrived(rows, day)).tolist():
order_id = rows.order_id[number]
payloads.append( payloads.append(
orjson.dumps( orjson.dumps(
{ {
+26 -9
View File
@@ -169,6 +169,16 @@ BUYER_CHECKOUT_OF_CART_PERCENT = 60
# страницу, на которой посетитель его нажал. # страницу, на которой посетитель его нажал.
TRADE_DELAY_SECONDS = (2, 8) TRADE_DELAY_SECONDS = (2, 8)
# Событийная сторона сверки: потерянные маячки purchase и повторная
# отправка пары «просмотр подтверждения + purchase». Замер (28 дней,
# канонический мир, 2026-08-30): дубль 1,89% — в пределах ориентира ~2%;
# потеря 1,78% при броске 3% — так и задумано: назначенные планом покупки
# из броска исключены (мост склейки), часть съедает приоритет отмены;
# наблюдаемая доля принята решением владельца (#194).
LOST_PURCHASE_PERCENT = 3
DUPLICATE_PURCHASE_PERCENT = 2
DUPLICATE_DELAY_SECONDS = (30, 301)
# Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла. # Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла.
# Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`): # Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`):
# магнит, обычный, залёживается. # магнит, обычный, залёживается.
@@ -238,8 +248,8 @@ GOAL_PURCHASE_ID = 42150002
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет # обычная курьерская, срочная. Это единственные деньги заказа, которых нет
# ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду # ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду
# считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок # считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок
# заказной стороны. Числа черновые: калибровка — пересборка эталонного # заказной стороны. Замер (28 дней, канонический мир, 2026-08-30): 35,5% /
# мира, этап 7. # 49,2% / 15,4% — совпадает с весами.
DELIVERY_KOPECKS_WEIGHTS = ( DELIVERY_KOPECKS_WEIGHTS = (
(0, 35), (0, 35),
(29_900, 50), (29_900, 50),
@@ -248,12 +258,13 @@ DELIVERY_KOPECKS_WEIGHTS = (
# Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке # Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке
# `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ # `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ
# выходит либо оплаченным, либо отменённым. Доли черновые: калибровка — # выходит либо оплаченным, либо отменённым. Замер (28 дней, 2026-08-30):
# пересборка эталонного мира, этап 7. # отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12% отменен
# неоплаченным; ориентир мастер-спеки ~5%.
ORDER_OUTCOME_WEIGHTS = ( ORDER_OUTCOME_WEIGHTS = (
95, # оплачен 95, # оплачен
3, # оплачен, потом отменён 3, # оплачен, потом отменён
2, # отменён неоплаченным 2, # отменен неоплаченным
) )
# Когда внутри окна случается момент заказа — оплата или отмена: веса часов # Когда внутри окна случается момент заказа — оплата или отмена: веса часов
@@ -261,8 +272,8 @@ ORDER_OUTCOME_WEIGHTS = (
# с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа, # с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа,
# рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в # рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в
# первые сутки, дальше затухание до края: # первые сутки, дальше затухание до края:
# профиль гаснет, поэтому обрыв окна в данных не виден. Веса черновые, как и # профиль гаснет, поэтому обрыв окна в данных не виден. Веса зафиксированы
# доли исходов. # вместе с долями исходов — тем же замером выше.
ORDER_MOMENT_HOUR_WEIGHTS = tuple( ORDER_MOMENT_HOUR_WEIGHTS = tuple(
weight weight
for hours, weight in ((6, 100), (18, 60), (24, 25), (48, 8), (48, 2)) for hours, weight in ((6, 100), (18, 60), (24, 25), (48, 8), (48, 2))
@@ -273,6 +284,12 @@ ORDER_MOMENT_HOUR_WEIGHTS = tuple(
# наличии, и заказ приезжает на строку короче клиентской корзины. Бросается # наличии, и заказ приезжает на строку короче клиентской корзины. Бросается
# доля на все заказы дня, а достаётся дельта не всем — однопозиционному заказу # доля на все заказы дня, а достаётся дельта не всем — однопозиционному заказу
# терять нечего, и таких больше половины, — поэтому в сверке наблюдаемая доля # терять нечего, и таких больше половины, — поэтому в сверке наблюдаемая доля
# выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Число # выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Замер
# черновое: калибровка — пересборка эталонного мира, этап 7. # (28 дней, 2026-08-30): наблюдаемая доля 1,32% — в пределах ориентира.
ORDER_DELTA_PERCENT = 4 ORDER_DELTA_PERCENT = 4
# На сколько первых дневных слепков заказ опоздает: веса δ = 0, 1, 2.
# Назначенные планом заказы после броска получают 0, чтобы мост склейки
# появился в стартовом мире. Замер (28 дней, 2026-08-30): доля опоздавших
# (δ>0) 8,39% — в пределах ориентира мастер-спеки ~10%.
ORDER_SNAPSHOT_DELAY_WEIGHTS = (90, 5, 5)
+123 -25
View File
@@ -21,7 +21,7 @@ from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2 WEEKDAY = 2
WEEK = 7 WEEK = 7
# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком — # Окно замера — две недели: доли по уровням спроса меряются на нем целиком —
# на одном дне у самого редкого уровня набирается слишком мало карточек. # на одном дне у самого редкого уровня набирается слишком мало карточек.
FORTNIGHT = 14 FORTNIGHT = 14
@@ -133,15 +133,15 @@ def opened_cards(events: day.Day) -> set[tuple[int, int]]:
) )
def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day): def test_only_a_lost_purchase_leaves_a_confirmation_without_purchase(
"""Клиентская сторона честная: подтверждение без покупки — это потеря. weekday: day.Day,
):
Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их """Потеря выбрасывает purchase, но оставляет просмотр confirmation."""
быть не должно ни одной. Обратное тоже верно: покупка без подтверждения
в потоке — заказ ниоткуда.
"""
bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist()) bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist())
assert confirmed_visits(weekday) == bought missing = confirmed_visits(weekday) - bought
lost = int((weekday.purchase_outcome == commerce.EventOutcome.LOST).sum())
assert len(missing) == lost > 0
assert bought <= confirmed_visits(weekday)
def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase( def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
@@ -159,7 +159,10 @@ def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
events = day.stream(CANONICAL_SEED, WEEKDAY) events = day.stream(CANONICAL_SEED, WEEKDAY)
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist()) bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
assert bought assert bought
assert confirmed_visits(events) == bought missing = confirmed_visits(events) - bought
lost = int((events.purchase_outcome == commerce.EventOutcome.LOST).sum())
assert len(missing) == lost
assert bought <= confirmed_visits(events)
def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day): def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day):
@@ -525,13 +528,15 @@ def test_the_goals_repeat_the_trade_events(weekday: day.Day):
def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day): def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day):
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда.""" """Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
orders = rows_of(weekday, commerce.PURCHASE) events = rows_of(weekday, commerce.PURCHASE)
numbers = [cell[0] for cell in orders["purchaseID"]] numbers = [cell[0] for cell in events["purchaseID"]]
date = str(weekday.columns["EventDate"][0]).replace("-", "") date = str(weekday.columns["EventDate"][0]).replace("-", "")
assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)] expected = [f"{date}-{place:04d}" for place in range(1, len(weekday.orders) + 1)]
assert len(set(numbers)) == len(numbers) # Порча идет после нумерации: потеря оставляет дыру, а дубль
# Нумерация идёт в порядке событий: строки дня уже упорядочены по времени. # повторяет готовый номер и не двигает соседей.
assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"]) assert list(weekday.orders.order_id) == expected
assert set(numbers) <= set(expected)
assert list(events["UTCEventTime"]) == sorted(events["UTCEventTime"])
def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue( def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue(
@@ -599,7 +604,23 @@ def test_the_trade_flow_draws_from_its_own_named_branch(
monkeypatch.setattr(commerce, "day_stream", spy) monkeypatch.setattr(commerce, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY) day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)] assert asked == [
(CANONICAL_SEED, WEEKDAY, Component.COMMERCE),
(CANONICAL_SEED, WEEKDAY, Component.BEACONS),
]
def test_an_assigned_purchase_is_never_lost(monkeypatch: pytest.MonkeyPatch):
"""Гарантия плана сильнее порчи: мост склейки не теряется."""
monkeypatch.setattr(world, "LOST_PURCHASE_PERCENT", 100)
monkeypatch.setattr(world, "DUPLICATE_PURCHASE_PERCENT", 0)
events = day.stream(CANONICAL_SEED, WEEKDAY)
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
promised = set(audience.client_id[audience.assigned_order].tolist())
bought = set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist())
assert promised
assert bought == promised
def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]): def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
@@ -627,6 +648,78 @@ def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs
def test_a_duplicate_repeats_the_confirmation_story(weekday: day.Day):
"""Обновление повторяет просмотр и purchase в том же визите."""
purchases = rows_of(weekday, commerce.PURCHASE)
ids_by_order: dict[str, list[int]] = {}
for row, cell in enumerate(purchases["purchaseID"]):
ids_by_order.setdefault(str(cell[0]), []).append(row)
duplicated = [rows for rows in ids_by_order.values() if len(rows) == 2]
expected = int((weekday.purchase_outcome == commerce.EventOutcome.DUPLICATED).sum())
assert len(duplicated) == expected > 0
for pair in duplicated:
first, second = sorted(pair, key=lambda row: purchases["UTCEventTime"][row])
delay = (
(purchases["UTCEventTime"][second] - purchases["UTCEventTime"][first])
.astype("timedelta64[s]")
.astype(int)
)
assert world.DUPLICATE_DELAY_SECONDS[0] <= delay
assert delay < world.DUPLICATE_DELAY_SECONDS[1]
assert delay < world.VISIT_TIMEOUT_SECONDS
assert purchases["VisitID"][first] == purchases["VisitID"][second]
assert purchases["WatchID"][first] != purchases["WatchID"][second]
for name in (
"purchaseID",
"purchaseRevenue",
"productID",
"productQuantity",
"ecommerce",
):
left, right = purchases[name][first], purchases[name][second]
if isinstance(left, np.ndarray):
assert np.array_equal(left, right), name
else:
assert left == right, name
visit = purchases["VisitID"][first]
confirmation = (
(weekday.columns["EventType"] == "pageview")
& (weekday.page == Page.CONFIRMATION)
& (weekday.columns["VisitID"] == visit)
)
views = np.flatnonzero(confirmation)
assert views.size == 2
assert len(set(weekday.columns["WatchID"][views].tolist())) == 2
view_delay = (
(
weekday.columns["UTCEventTime"][views].max()
- weekday.columns["UTCEventTime"][views].min()
)
.astype("timedelta64[s]")
.astype(int)
)
assert view_delay == delay
cookie = purchases["ClientID"][first]
later_visit = (
(weekday.columns["ClientID"] == cookie)
& (weekday.columns["VisitID"] != visit)
& (weekday.columns["UTCEventTime"] > purchases["UTCEventTime"][second])
)
if np.any(later_visit):
gap = (
(
weekday.columns["UTCEventTime"][later_visit].min()
- purchases["UTCEventTime"][second]
)
.astype("timedelta64[s]")
.astype(int)
)
assert gap > world.VISIT_TIMEOUT_SECONDS
def paired_cookies() -> set[int]: def paired_cookies() -> set[int]:
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём.""" """Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
cookies: set[int] = set() cookies: set[int] = set()
@@ -642,21 +735,26 @@ def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]):
«Постоянный покупатель» читается только так — как кука, которая ходит «Постоянный покупатель» читается только так — как кука, которая ходит
неделями и покупает не раз: метки в событии нет и не будет. неделями и покупает не раз: метки в событии нет и не будет.
Куки двухкуковых пар из счёта исключены: их заказы назначил план, и Куки двухкуковых пар из счета исключены: их заказы назначил план, и
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются вместе с ними лифт мерил бы гарантию, а не два новых рычага. Даже после
те помеченные, чьи покупки решил день: повторно покупают 4,6% из них событийной порчи оставшиеся помеченные покупают повторно более чем вдвое
против 1,5% прочих — втрое чаще при пороге «вдвое». чаще прочих.
""" """
purchases: dict[int, int] = {} purchases: dict[int, set[str]] = {}
flagged: set[int] = set() flagged: set[int] = set()
for number, events in enumerate(week): for number, events in enumerate(week):
audience = plan.audience(CANONICAL_SEED, number) audience = plan.audience(CANONICAL_SEED, number)
flagged |= set(audience.client_id[audience.buyer].tolist()) flagged |= set(audience.client_id[audience.buyer].tolist())
for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist(): rows = rows_of(events, commerce.PURCHASE)
purchases[cookie] = purchases.get(cookie, 0) + 1 for cookie, order_id in zip(
rows["ClientID"].tolist(),
(str(cell[0]) for cell in rows["purchaseID"]),
strict=True,
):
purchases.setdefault(cookie, set()).add(order_id)
def repeat_share(cookies: set[int]) -> float: def repeat_share(cookies: set[int]) -> float:
again = sum(1 for cookie in cookies if purchases[cookie] > 1) again = sum(1 for cookie in cookies if len(purchases[cookie]) > 1)
return again / len(cookies) return again / len(cookies)
buyers = set(purchases) buyers = set(purchases)
+3
View File
@@ -68,6 +68,7 @@ def orders(events: day.Day) -> list[list[Any]]:
int(theirs.outcome[number]), int(theirs.outcome[number]),
int(theirs.paid_after[number]), int(theirs.paid_after[number]),
int(theirs.cancelled_after[number]), int(theirs.cancelled_after[number]),
int(theirs.snapshot_delay[number]),
] ]
for number in range(len(theirs)) for number in range(len(theirs))
] ]
@@ -88,6 +89,7 @@ def snapshot(events: day.Day) -> dict[str, Any]:
"page": events.page.tolist(), "page": events.page.tolist(),
"product": events.product.tolist(), "product": events.product.tolist(),
"orders": orders(events), "orders": orders(events),
"purchase_outcome": events.purchase_outcome.tolist(),
} }
@@ -115,6 +117,7 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
page=weekday.page, page=weekday.page,
product=weekday.product, product=weekday.product,
orders=weekday.orders, orders=weekday.orders,
purchase_outcome=weekday.purchase_outcome,
) )
assert snapshot(reordered) != original assert snapshot(reordered) != original
+32
View File
@@ -0,0 +1,32 @@
"""Запускаемый путеводитель остается коротким и проходит всю цепочку."""
import subprocess
from pathlib import Path
def test_demo_runs_and_shows_the_whole_chain() -> None:
"""Сторожит команду и стадии, а числа мира оставляет описи."""
finished = subprocess.run(
["make", "--silent", "demo"],
check=False,
capture_output=True,
cwd=Path(__file__).parents[1],
text=True,
)
assert finished.returncode == 0, finished.stderr
lines = finished.stdout.splitlines()
assert [line.partition(":")[0] for line in lines] == [
"Зерно",
"Подпотоки D0",
"Когорта D0",
"Аудитория D0",
"Визиты по VisitID",
"Строки событий",
]
assert "когорты D-" in lines[3] and "...D0" in lines[3]
assert "строк в группе" in lines[4]
assert all(
label in lines[5]
for label in ("pageview", "add_to_cart", "purchase", "заказов")
)
+26 -1
View File
@@ -27,6 +27,9 @@ INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory
# отправляемых; этот дешевле прочих — его окно короче. # отправляемых; этот дешевле прочих — его окно короче.
RUN_DAY = 2 RUN_DAY = 2
# День любой: хеш дня не зависит ни от соседей, ни от края описи.
REPLAY_DAY = 4
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def inventory() -> dict: def inventory() -> dict:
@@ -48,7 +51,15 @@ def test_order_class_counters_cover_only_closed_days(inventory: dict):
rows = [row for row in inventory["days"] if "orders" in row] rows = [row for row in inventory["days"] if "orders" in row]
assert [row["day"] for row in rows] == [0] assert [row["day"] for row in rows] == [0]
assert set(rows[0]["orders"]) == {"match", "cancelled", "amount_delta"} assert set(rows[0]["orders"]) == {
"match",
"cancelled",
"lost_event",
"duplicate_event",
"amount_delta",
}
assert rows[0]["orders"]["lost_event"] > 0
assert rows[0]["orders"]["duplicate_event"] > 0
today = day_module.stream(CANONICAL_SEED, rows[0]["day"]) today = day_module.stream(CANONICAL_SEED, rows[0]["day"])
assert sum(rows[0]["orders"].values()) == len(today.orders) assert sum(rows[0]["orders"].values()) == len(today.orders)
@@ -72,3 +83,17 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm
row = next(row for row in inventory["snapshots"] if row["day"] == RUN_DAY - 1) row = next(row for row in inventory["snapshots"] if row["day"] == RUN_DAY - 1)
assert row["sha256"] == sent assert row["sha256"] == sent
def test_a_replayed_day_matches_its_inventory_hash(tmp_path):
"""Опись сверяется с байтами, вышедшими из CLI, а не с собранной тут же:
пересчет сравнивался бы сам с собой.
"""
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
path = tmp_path / "day.jsonl"
assert cli.main(["batch", "--day", str(REPLAY_DAY), "--file", str(path)]) == 0
sent = hashlib.sha256(path.read_bytes()).hexdigest()
row = next(row for row in stored["days"] if row["day"] == REPLAY_DAY)
assert row["sha256"] == sent
+63 -18
View File
@@ -58,17 +58,52 @@ def user_id_by_order(events: day.Day) -> dict[str, int]:
) )
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day): def purchase_row_by_order(
"""Заказ — вторая проекция покупки, а не второе порождение. events: day.Day,
) -> tuple[dict[str, NDArray[Any]], dict[str, int]]:
"""Первая строка purchase каждого доехавшего заказа."""
rows = purchases_of(events)
index: dict[str, int] = {}
for row, cell in enumerate(rows["purchaseID"]):
index.setdefault(str(cell[0]), row)
return rows, index
У всякого заказа ровно одно событие `purchase`, и номер у них один:
сойтись двум источникам больше негде — сверка стенда соединяется def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
именно по нему. """Заказ рождается до потери или дубля его событийной проекции."""
"""
events = purchases_of(weekday) events = purchases_of(weekday)
numbers = [cell[0] for cell in events["purchaseID"]] numbers = [cell[0] for cell in events["purchaseID"]]
assert len(numbers) > 100 assert len(numbers) > 100
assert list(weekday.orders.order_id) == numbers lost = {
order_id
for order_id, outcome in zip(
weekday.orders.order_id, weekday.purchase_outcome, strict=True
)
if outcome == commerce.EventOutcome.LOST
}
assert set(numbers) == set(weekday.orders.order_id) - lost
assert len(numbers) - len(set(numbers)) == int(
(weekday.purchase_outcome == commerce.EventOutcome.DUPLICATED).sum()
)
def test_an_assigned_order_never_waits_for_a_later_snapshot(
monkeypatch: pytest.MonkeyPatch,
):
"""Гарантия плана сильнее опоздания: мост склейки приезжает сразу."""
# Все неназначенные заказы опаздывают на два дня. Нулевую задержку после
# такого броска могут получить только заказы, защищённые планом.
monkeypatch.setattr(
orders, "_SNAPSHOT_DELAY_CUMULATIVE", np.array([0, 0, 1], dtype=np.int64)
)
events = day.stream(CANONICAL_SEED, WEEKDAY)
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
promised = set(audience.person_id[audience.assigned_order].tolist())
immediate = set(events.orders.user_id[events.orders.snapshot_delay == 0].tolist())
assert promised
assert immediate == promised
assert (events.orders.snapshot_delay == 2).any()
def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]): def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]):
@@ -88,16 +123,18 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
""" """
goods = catalog.catalog() goods = catalog.catalog()
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True)) price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
events = purchases_of(weekday) events, row_by_order = purchase_row_by_order(weekday)
money = weekday.orders money = weekday.orders
deltas = 0 deltas = 0
for number, order in enumerate(weekday.orders.order_id): for number, order in enumerate(weekday.orders.order_id):
assert order == events["purchaseID"][number][0] if weekday.purchase_outcome[number] == commerce.EventOutcome.LOST:
continue
row = row_by_order[order]
client = list( client = list(
zip( zip(
events["productID"][number].tolist(), events["productID"][row].tolist(),
events["productQuantity"][number].tolist(), events["productQuantity"][row].tolist(),
strict=True, strict=True,
) )
) )
@@ -110,7 +147,7 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
) )
# Выручка клиента и `items_total` бэкенда — одно число: в событии # Выручка клиента и `items_total` бэкенда — одно число: в событии
# оно дробное, у заказа целое в копейках. # оно дробное, у заказа целое в копейках.
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS) revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
if mine == client: if mine == client:
assert money.items_total[number] == revenue assert money.items_total[number] == revenue
continue continue
@@ -136,15 +173,18 @@ def test_the_discount_comes_from_the_coupon_of_the_event(
for today in start_world: for today in start_world:
money = today.orders money = today.orders
events = purchases_of(today) events, row_by_order = purchase_row_by_order(today)
codes = [cell[0] for cell in events["purchaseCoupon"]] for number, order_id in enumerate(money.order_id):
for number, code in enumerate(codes): if today.purchase_outcome[number] == commerce.EventOutcome.LOST:
continue
row = row_by_order[order_id]
code = events["purchaseCoupon"][row][0]
items_total = money.items_total[number] items_total = money.items_total[number]
expected = items_total * percent[code] // 100 if code else 0 expected = items_total * percent[code] // 100 if code else 0
assert money.discount[number] == expected, money.order_id[number] assert money.discount[number] == expected, money.order_id[number]
assert money.discount[number] < items_total assert money.discount[number] < items_total
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS) revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
discounted_delta += int(bool(code) and items_total != revenue) discounted_delta += int(bool(code) and items_total != revenue)
# Иначе проверка не отличила бы новую базу скидки от прежней. # Иначе проверка не отличила бы новую базу скидки от прежней.
@@ -255,8 +295,13 @@ def test_the_user_id_is_the_person_behind_the_cookie(weekday: day.Day):
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True) zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
) )
events = purchases_of(weekday) events = purchases_of(weekday)
for number, cookie in enumerate(events["ClientID"].tolist()): by_order = user_id_by_order(weekday)
assert weekday.orders.user_id[number] == person[cookie] for order_id, cookie in zip(
(cell[0] for cell in events["purchaseID"]),
events["ClientID"].tolist(),
strict=True,
):
assert by_order[order_id] == person[cookie]
assert np.all(weekday.orders.user_id > 0) assert np.all(weekday.orders.user_id > 0)
assert weekday.orders.user_id.max() < 2**53 assert weekday.orders.user_id.max() < 2**53
+12 -12
View File
@@ -14,8 +14,8 @@ import pytest
from clickstream_generator import plan, reference, world from clickstream_generator import plan, reference, world
from clickstream_generator.seeds import CANONICAL_SEED from clickstream_generator.seeds import CANONICAL_SEED
# Горизонт эталонного снимка — две недели (спека, раздел 5). # Окно замера плана — две недели: два цикла недельной сезонности.
SNAPSHOT_DAYS = 14 WINDOW_DAYS = 14
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -54,9 +54,9 @@ def test_the_plan_is_a_pure_function_of_the_seed():
def test_counters_are_a_pure_function_of_the_seed(): def test_counters_are_a_pure_function_of_the_seed():
first = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) first = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
plan.cohort.cache_clear() plan.cohort.cache_clear()
assert first == plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) assert first == plan.counters(CANONICAL_SEED, WINDOW_DAYS)
def test_a_remembered_cohort_cannot_be_spoiled_from_outside(): def test_a_remembered_cohort_cannot_be_spoiled_from_outside():
@@ -216,12 +216,12 @@ def test_the_passport_points_into_the_directories():
def test_the_daily_audience_matches_the_spec_band(): def test_the_daily_audience_matches_the_spec_band():
"""6–8 тыс. посетителей в день — правдоподобный средний магазин.""" """6–8 тыс. посетителей в день — правдоподобный средний магазин."""
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
assert all(6_000 <= size <= 8_000 for size in counters.audience) assert all(6_000 <= size <= 8_000 for size in counters.audience)
def test_the_influx_holds_its_daily_number(): def test_the_influx_holds_its_daily_number():
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
average = sum(counters.new_cookies) / len(counters.new_cookies) average = sum(counters.new_cookies) / len(counters.new_cookies)
assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10 assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10
@@ -232,7 +232,7 @@ def test_the_influx_breathes_with_the_week():
def mean(numbers: tuple[int, ...] | list[int]) -> float: def mean(numbers: tuple[int, ...] | list[int]) -> float:
return sum(numbers) / len(numbers) return sum(numbers) / len(numbers)
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
weekdays = [size for day, size in enumerate(counters.new_cookies) if day % 7 < 5] weekdays = [size for day, size in enumerate(counters.new_cookies) if day % 7 < 5]
weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5] weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5]
expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean( expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean(
@@ -250,7 +250,7 @@ def test_the_influx_differs_even_on_the_same_weekday():
def test_the_influx_is_visible_on_the_plan(): def test_the_influx_is_visible_on_the_plan():
"""Накопленная аудитория растёт с горизонтом и с дневной не сходится.""" """Накопленная аудитория растёт с горизонтом и с дневной не сходится."""
week = plan.counters(CANONICAL_SEED, 7) week = plan.counters(CANONICAL_SEED, 7)
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) fortnight = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
assert week.visitors < fortnight.visitors assert week.visitors < fortnight.visitors
assert fortnight.visitors > 2 * max(fortnight.audience) assert fortnight.visitors > 2 * max(fortnight.audience)
@@ -258,21 +258,21 @@ def test_the_influx_is_visible_on_the_plan():
def test_the_horizon_is_a_prefix_not_another_world(): def test_the_horizon_is_a_prefix_not_another_world():
"""Продление истории днём N+1 не трогает дни 1…N.""" """Продление истории днём N+1 не трогает дни 1…N."""
week = plan.counters(CANONICAL_SEED, 7) week = plan.counters(CANONICAL_SEED, 7)
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) fortnight = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
assert fortnight.audience[:7] == week.audience assert fortnight.audience[:7] == week.audience
assert fortnight.new_cookies[:7] == week.new_cookies assert fortnight.new_cookies[:7] == week.new_cookies
def test_counters_count_the_same_audience_that_the_day_gets(): def test_counters_count_the_same_audience_that_the_day_gets():
"""Счётчики и дня-функция спрашивают один план — расходиться им негде.""" """Счётчики и дня-функция спрашивают один план — расходиться им негде."""
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
for day in (0, 7, SNAPSHOT_DAYS - 1): for day in (0, 7, WINDOW_DAYS - 1):
actual = plan.audience(CANONICAL_SEED, day).client_id.size actual = plan.audience(CANONICAL_SEED, day).client_id.size
assert actual == counters.audience[day] assert actual == counters.audience[day]
def test_counters_know_the_pairs_before_a_single_event(): def test_counters_know_the_pairs_before_a_single_event():
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
assert counters.pairs > 0 assert counters.pairs > 0
+1 -1
View File
@@ -78,7 +78,7 @@ def test_day_components_do_not_share_randomness():
Component.TRAFFIC, Component.TRAFFIC,
Component.COMMERCE, Component.COMMERCE,
Component.ORDERS, Component.ORDERS,
Component.LATECOMERS, Component.BEACONS,
) )
assert len({int(component) for component in components}) == 4 assert len({int(component) for component in components}) == 4
draws = { draws = {
+63 -1
View File
@@ -128,6 +128,8 @@ def test_the_record_matches_the_wire_contract(days: list[day_module.Day]):
) )
if _born(record) == _date(SHORT_WINDOW_DAY): if _born(record) == _date(SHORT_WINDOW_DAY):
# Потерянного purchase в трекере нет, но заказ остается.
if record["order_id"] in bought:
assert record["created_at"][:19] + "Z" == bought[record["order_id"]] assert record["created_at"][:19] + "Z" == bought[record["order_id"]]
@@ -174,6 +176,63 @@ def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]):
assert len(set(numbers)) == len(numbers) assert len(set(numbers)) == len(numbers)
def test_lateness_changes_only_the_rows_present_in_a_snapshot(
days: list[day_module.Day],
):
"""Неопоздавшие строки остаются прежними байтами и в прежнем порядке."""
number = SHORT_WINDOW_DAY
window = [today.orders for today in days[: number + 1]]
eager = serialize.orders(
[
replace(rows, snapshot_delay=np.zeros_like(rows.snapshot_delay))
for rows in window
],
number,
)
actual = serialize.orders(window, number)
actual_ids = {json.loads(payload)["order_id"] for payload in actual}
expected = [
payload for payload in eager if json.loads(payload)["order_id"] in actual_ids
]
assert len(actual) < len(eager)
assert actual == expected
def test_a_two_day_late_order_first_arrives_in_its_lived_state(
days: list[day_module.Day],
):
"""Заказ δ = 2 пропускает D и D+1 и догоняет жизнь, не меняя `created_at`."""
honest = days[0].orders
target = 0
delay = np.zeros_like(honest.snapshot_delay)
delay[target] = 2
cancelled_after = honest.cancelled_after.copy()
cancelled_after[target] = 30 * 3600
late = replace(
honest,
snapshot_delay=delay,
cancelled_after=cancelled_after,
)
order_id = late.order_id[target]
for number in (0, 1):
assert order_id not in {
json.loads(payload)["order_id"]
for payload in serialize.orders([late], number)
}
arrived = {
record["order_id"]: record
for record in map(json.loads, serialize.orders([late], 2))
}[order_id]
created_at = np.datetime_as_string(
late.created_at[target], unit="ms", timezone="UTC"
)
assert arrived["status"] == "cancelled"
assert arrived["created_at"] == created_at
def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]): def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]):
"""Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`. """Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`.
@@ -325,7 +384,10 @@ def _purchase_times(today: day_module.Day) -> dict[str, str]:
times = np.datetime_as_string( times = np.datetime_as_string(
today.columns["UTCEventTime"][here], unit="s", timezone="UTC" today.columns["UTCEventTime"][here], unit="s", timezone="UTC"
) )
return dict(zip(numbers, times.tolist(), strict=True)) result: dict[str, str] = {}
for number, moment in zip(numbers, times.tolist(), strict=True):
result.setdefault(number, moment)
return result
def _born(record: dict) -> date: def _born(record: dict) -> date:
+5
View File
@@ -122,6 +122,11 @@ def test_the_trade_event_never_overtakes_the_next_page():
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS) assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
def test_the_duplicate_delay_stays_inside_a_visit_timeout():
assert 0 < min(world.DUPLICATE_DELAY_SECONDS)
assert max(world.DUPLICATE_DELAY_SECONDS) <= world.VISIT_TIMEOUT_SECONDS
def conversion(cart: int, checkout_of_cart: int) -> float: def conversion(cart: int, checkout_of_cart: int) -> float:
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки.""" """Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2 return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
+3 -1
View File
@@ -6,6 +6,8 @@ FROM grafana/grafana:13.1.1
USER root USER root
ENV GF_PATHS_PLUGINS=/usr/share/grafana/plugins \ ENV GF_PATHS_PLUGINS=/usr/share/grafana/plugins \
GF_PLUGINS_PREINSTALL_DISABLED=true GF_PLUGINS_PREINSTALL_DISABLED=true
ARG CLICKHOUSE_PLUGIN_VERSION=4.20.0
RUN grafana cli --pluginsDir "$GF_PATHS_PLUGINS" \ RUN grafana cli --pluginsDir "$GF_PATHS_PLUGINS" \
plugins install grafana-clickhouse-datasource 4.20.0 --pluginUrl "https://github.com/grafana/clickhouse-datasource/releases/download/v${CLICKHOUSE_PLUGIN_VERSION}/grafana-clickhouse-datasource-${CLICKHOUSE_PLUGIN_VERSION}.zip" \
plugins install grafana-clickhouse-datasource "$CLICKHOUSE_PLUGIN_VERSION"
USER grafana USER grafana
@@ -0,0 +1,31 @@
slice_name: Сверка purchase и заказов
description: null
certified_by: null
certification_details: null
viz_type: pivot_table_v2
params:
viz_type: pivot_table_v2
groupbyRows:
- order_day
groupbyColumns:
- mismatch_class
metrics:
- order_count
adhoc_filters:
- expressionType: SIMPLE
subject: order_day
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
aggregateFunction: Sum
metricsLayout: COLUMNS
rowOrder: key_a_to_z
colOrder: key_a_to_z
order_desc: true
row_limit: 10000
valueFormat: SMART_NUMBER
date_format: smart_date
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000102
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000002
@@ -0,0 +1,30 @@
slice_name: Выручка по дням и категориям
description: null
certified_by: null
certification_details: null
viz_type: echarts_timeseries_bar
params:
viz_type: echarts_timeseries_bar
x_axis: report_date
metrics:
- revenue
groupby:
- product_category
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
row_limit: 10000
stack: Stack
orientation: vertical
show_legend: true
rich_tooltip: true
show_value: false
y_axis_format: ',.2f'
x_axis_time_format: smart_date
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000101
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -0,0 +1,29 @@
slice_name: Посетители и известные пользователи
description: null
certified_by: null
certification_details: null
viz_type: echarts_timeseries_line
params:
viz_type: echarts_timeseries_line
x_axis: report_date
metrics:
- visitors
- known_users
groupby: []
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
row_limit: 10000
seriesType: echarts_timeseries_line
show_legend: true
rich_tooltip: true
show_value: false
y_axis_format: ',d'
x_axis_time_format: smart_date
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000103
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000003
@@ -0,0 +1,105 @@
dashboard_title: Бизнес-сюжеты
description: null
css: null
slug: business-stories
certified_by: null
certification_details: null
published: true
uuid: 19600000-0000-4000-8000-000000000201
position:
DASHBOARD_VERSION_KEY: v2
ROOT_ID:
id: ROOT_ID
type: ROOT
children:
- GRID_ID
GRID_ID:
id: GRID_ID
type: GRID
parents:
- ROOT_ID
children:
- ROW-revenue
- ROW-pvo
- ROW-traffic
ROW-revenue:
id: ROW-revenue
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-revenue
meta:
background: BACKGROUND_TRANSPARENT
CHART-revenue:
id: CHART-revenue
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-revenue
children: []
meta:
chartId: 1
height: 50
width: 12
sliceName: Выручка по дням и категориям
uuid: 19600000-0000-4000-8000-000000000101
ROW-pvo:
id: ROW-pvo
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-pvo
meta:
background: BACKGROUND_TRANSPARENT
CHART-pvo:
id: CHART-pvo
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-pvo
children: []
meta:
chartId: 2
height: 50
width: 12
sliceName: Сверка purchase и заказов
uuid: 19600000-0000-4000-8000-000000000102
ROW-traffic:
id: ROW-traffic
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-traffic
meta:
background: BACKGROUND_TRANSPARENT
CHART-traffic:
id: CHART-traffic
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-traffic
children: []
meta:
chartId: 3
height: 50
width: 12
sliceName: Посетители и известные пользователи
uuid: 19600000-0000-4000-8000-000000000103
metadata:
color_scheme: supersetColors
cross_filters_enabled: false
chart_configuration: {}
expanded_slices: {}
default_filters: '{}'
native_filter_configuration: []
theme_uuid: null
version: 1.0.0
@@ -0,0 +1,106 @@
table_name: daily_traffic_v
main_dttm_col: report_date
currency_code_column: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
catalog: null
schema: dm
sql: null
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
always_filter_main_dttm: false
folders: null
uuid: 19600000-0000-4000-8000-000000000003
metrics:
- metric_name: known_users
verbose_name: Известные пользователи
metric_type: null
expression: SUM(known_users)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
- metric_name: visitors
verbose_name: Посетители
metric_type: null
expression: SUM(visitors)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
columns:
- column_name: _load_ts
verbose_name: Время загрузки
is_dttm: true
is_active: true
type: DATETIME64(3, 'UTC')
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: known_users
verbose_name: Известные пользователи
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_id
verbose_name: Запуск загрузки
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: visitors
verbose_name: Посетители
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: report_date
verbose_name: Дата
is_dttm: true
is_active: true
type: DATE
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
version: 1.0.0
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
@@ -0,0 +1,136 @@
table_name: purchase_vs_orders_v
main_dttm_col: order_day
currency_code_column: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
catalog: null
schema: dm
sql: null
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
always_filter_main_dttm: false
folders: null
uuid: 19600000-0000-4000-8000-000000000002
metrics:
- metric_name: order_count
verbose_name: Количество заказов
metric_type: null
expression: COUNT(order_id)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
columns:
- column_name: items_total
verbose_name: Сумма позиций заказа
is_dttm: false
is_active: true
type: NULLABLE(DECIMAL(18, 2))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_ts
verbose_name: Время загрузки
is_dttm: true
is_active: true
type: DATETIME64(3, 'UTC')
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: declared_revenue
verbose_name: Выручка purchase
is_dttm: false
is_active: true
type: NULLABLE(DECIMAL(18, 2))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: status
verbose_name: Статус заказа
is_dttm: false
is_active: true
type: LOWCARDINALITY(NULLABLE(STRING))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: mismatch_class
verbose_name: Класс расхождения
is_dttm: false
is_active: true
type: LOWCARDINALITY(STRING)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: order_day
verbose_name: День заказа
is_dttm: true
is_active: true
type: DATE
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_id
verbose_name: Запуск загрузки
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: order_id
verbose_name: Заказ
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
version: 1.0.0
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
@@ -0,0 +1,138 @@
table_name: revenue_daily_v
main_dttm_col: report_date
currency_code_column: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
catalog: null
schema: dm
sql: null
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
always_filter_main_dttm: false
folders: null
uuid: 19600000-0000-4000-8000-000000000001
metrics:
- metric_name: revenue
verbose_name: Выручка
metric_type: null
expression: SUM(revenue)
description: null
d3format: ',.2f'
currency:
symbol: ₽
symbolPosition: suffix
extra: null
warning_text: null
columns:
- column_name: _load_ts
verbose_name: Время загрузки
is_dttm: true
is_active: true
type: DATETIME64(3, 'UTC')
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: aov
verbose_name: Средний чек
is_dttm: false
is_active: true
type: DECIMAL(18, 2)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: revenue
verbose_name: Выручка
is_dttm: false
is_active: true
type: DECIMAL(18, 2)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: product_category
verbose_name: Категория товара
is_dttm: false
is_active: true
type: LOWCARDINALITY(STRING)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_id
verbose_name: Запуск загрузки
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: orders
verbose_name: Заказы
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: units
verbose_name: Товары
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: report_date
verbose_name: Дата
is_dttm: true
is_active: true
type: DATE
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
version: 1.0.0
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
-2
View File
@@ -1,3 +1 @@
version: 1.0.0 version: 1.0.0
type: Database
timestamp: '2026-07-30T00:00:00+00:00'
+51 -23
View File
@@ -237,35 +237,42 @@ check_airflow_probes() {
} }
check_superset() { check_superset() {
local clickhouse_password local access_token
local available_schemas
local clickhouse_databases
local config local config
local connection_count
local connection_details
local connection_id
local database_list
local login local login
local metadata_tables local metadata_tables
local password local password
local port local port
local metadata_engine local schemas
local stored_config local stored_safe_uri
local stored_uri
local stored_uuid local stored_uuid
local superset_password local superset_password
local superset_user local superset_user
local superset_url
local user local user
config="$(compose config --format json 2>/dev/null || true)" config="$(compose config --format json 2>/dev/null || true)"
user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // empty' <<<"$config")" user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // empty' <<<"$config")"
password="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_PASSWORD // empty' <<<"$config")" password="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_PASSWORD // empty' <<<"$config")"
clickhouse_password="$(jq -r '.services.superset.environment.CLICKHOUSE_BI_PASSWORD // empty' <<<"$config")"
superset_user="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_USER // empty' <<<"$config")" superset_user="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_USER // empty' <<<"$config")"
superset_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")" superset_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")"
port="$(published_port superset 8088)" port="$(published_port superset 8088)"
superset_url="http://127.0.0.1:${port}"
login="$(curl -sf --max-time 10 -X POST \ login="$(curl -sf --max-time 10 -X POST \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "$(jq -cn --arg username "$user" --arg password "$password" \ -d "$(jq -cn --arg username "$user" --arg password "$password" \
'{username: $username, password: $password, provider: "db", refresh: true}')" \ '{username: $username, password: $password, provider: "db", refresh: true}')" \
"http://127.0.0.1:${port}/api/v1/security/login" 2>/dev/null || true)" "${superset_url}/api/v1/security/login" 2>/dev/null || true)"
if curl -sf --max-time 10 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && \ access_token="$(jq -r '.access_token // empty' <<<"$login")"
jq -e '.access_token | length > 0' >/dev/null 2>&1 <<<"$login"; then if curl -sf --max-time 10 "${superset_url}/health" >/dev/null 2>&1 && \
[[ -n "$access_token" ]]; then
pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора' pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора'
else else
fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора' fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора'
@@ -276,28 +283,49 @@ check_superset() {
psql -U "$superset_user" -d superset -tAc \ psql -U "$superset_user" -d superset -tAc \
"SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \ "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \
2>/dev/null || true)" 2>/dev/null || true)"
stored_config="$(timeout 30s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
exec -T superset python -c \ # Superset уже собрал приложение. Его REST API читает ту же сохранённую
"from superset.app import create_app; app = create_app(); app.app_context().push(); from superset.extensions import db; from superset.models.core import Database; url = db.engine.url; database = db.session.query(Database).filter_by(database_name='ClickHouse').one(); print('SMOKE_METADATA=' + '|'.join([url.get_backend_name(), url.username or '', url.host or '', str(url.port or ''), url.database or ''])); print('SMOKE_UUID=' + str(database.uuid)); print('SMOKE_URI=' + database.sqlalchemy_uri_decrypted)" \ # модель Database без второго тяжёлого запуска Python.
2>/dev/null)" database_list="$(curl -sf --max-time 10 \
metadata_engine="$(sed -n 's/^SMOKE_METADATA=//p' <<<"$stored_config" | tail -n 1)" -H "Authorization: Bearer ${access_token}" \
stored_uuid="$(sed -n 's/^SMOKE_UUID=//p' <<<"$stored_config" | tail -n 1)" "${superset_url}/api/v1/database/" 2>/dev/null || true)"
stored_uri="$(sed -n 's/^SMOKE_URI=//p' <<<"$stored_config" | tail -n 1)" clickhouse_databases="$(jq -c \
'[.result[]? | select(.database_name == "ClickHouse")]' \
<<<"$database_list")"
connection_count="$(jq -r 'length' <<<"$clickhouse_databases")"
connection_id="$(jq -r 'if length == 1 then .[0].id else empty end' \
<<<"$clickhouse_databases")"
stored_uuid="$(jq -r 'if length == 1 then .[0].uuid else empty end' \
<<<"$clickhouse_databases")"
connection_details=''
if [[ -n "$connection_id" ]]; then
connection_details="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${access_token}" \
"${superset_url}/api/v1/database/${connection_id}/connection" \
2>/dev/null || true)"
fi
stored_safe_uri="$(jq -r '.result.sqlalchemy_uri // empty' \
<<<"$connection_details")"
if [[ "$metadata_tables" == '1' ]] && \ if [[ "$metadata_tables" == '1' ]] && \
[[ "$metadata_engine" == "postgresql|${superset_user}|postgres-metadata|5432|superset" ]] && \
[[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \ [[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \
[[ "$stored_uri" == "clickhousedb://bi:${clickhouse_password}@clickhouse-02:8123/default" ]]; then [[ "$stored_safe_uri" == 'clickhousedb://bi@clickhouse-02:8123/default' ]]; then
pass 'метаданные Superset живут в Postgres, подготовленное подключение указывает на clickhouse-02' pass 'Postgres для метаданных Superset подготовлен, подключение указывает на clickhouse-02'
else else
fail "Superset не подтвердил Postgres и подготовленное подключение bi к ноде 2: таблицы=${metadata_tables:-нет}, движок=${metadata_engine:-нет}, UUID=${stored_uuid:-нет}" fail "Postgres или подключение Superset не подготовлены: таблицы=${metadata_tables:-нет}, подключений=${connection_count:-нет}, UUID=${stored_uuid:-нет}, URI=${stored_safe_uri:-нет}"
return return
fi fi
if printf 'n\n' | timeout 60s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ schemas="$(curl -sf --max-time 10 \
exec -T superset superset test-db "$stored_uri" >/dev/null 2>&1; then -H "Authorization: Bearer ${access_token}" \
pass 'Superset успешно проверил извлечённое из метаданных подключение ClickHouse' "${superset_url}/api/v1/database/${connection_id}/schemas/" \
2>/dev/null || true)"
available_schemas="$(jq -r '[.result[]?] | join(", ")' <<<"$schemas")"
if jq -e '.result | index("dm") != null' >/dev/null 2>&1 <<<"$schemas"; then
pass 'сохранённое подключение Superset видит схему витрин dm'
else else
fail 'Superset не смог проверить извлечённое из метаданных подключение ClickHouse' fail "сохранённое подключение Superset не видит схему витрин dm: схемы=${available_schemas:-нет}"
fi fi
} }
+1 -1
View File
@@ -83,7 +83,7 @@ CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
GoalsReached Array(UInt32), GoalsReached Array(UInt32),
ParsedParamsKey1 Array(String), ParsedParamsKey1 Array(String),
purchaseID Array(String), purchaseID Array(String),
purchaseRevenue Array(Float64), purchaseRevenue Array(Decimal(18, 2)),
purchaseCurrency Array(String), purchaseCurrency Array(String),
purchaseCoupon Array(String), purchaseCoupon Array(String),
productID Array(String), productID Array(String),
+5 -1
View File
@@ -154,7 +154,11 @@ SELECT
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached, JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1, JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID, JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
JSONExtract(raw, 'purchaseRevenue', 'Array(Float64)') AS purchaseRevenue, -- Выгрузка несет JSON-числа типа Float64, но деньги хранятся в Decimal.
-- JSONExtract разбирает их сразу, без промежуточного двоичного числа:
-- синтаксис подтвержден документацией ClickHouse через Context7 и
-- запросом к стендовому ClickHouse 26.3 при исполнении #155.
JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue,
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency, JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon, JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
JSONExtract(raw, 'productID', 'Array(String)') AS productID, JSONExtract(raw, 'productID', 'Array(String)') AS productID,
+93 -1
View File
@@ -1,4 +1,4 @@
-- DM: физические дневные витрины и донор партиций выручки. -- DM: физические дневные витрины и доноры их партиций.
-- Выручка хранится в зерне «день заказа × категория товара». День служит -- Выручка хранится в зерне «день заказа × категория товара». День служит
-- единицей пересборки, поэтому совпадает с ключом партиции. -- единицей пересборки, поэтому совпадает с ключом партиции.
@@ -48,6 +48,51 @@ ENGINE = Distributed(
cityHash64(product_category) cityHash64(product_category)
); );
-- Сверка хранит одну строку на заказ. День — единица пересборки: пока рядом
-- живут два источника, строка может перейти из awaiting_order в обычный класс.
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_rep ON CLUSTER clickstream_cluster
(
order_day Date,
order_id String,
declared_revenue Nullable(Decimal(18, 2)),
items_total Nullable(Decimal(18, 2)),
status LowCardinality(Nullable(String)),
mismatch_class LowCardinality(String),
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY order_day
ORDER BY (order_day, order_id);
-- Ключ совпадает с DDS, поэтому строка заказа на всех слоях попадает на один
-- шард.
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_dist ON CLUSTER clickstream_cluster
AS dm.purchase_vs_orders_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'purchase_vs_orders_rep',
cityHash64(order_id)
);
-- Донор повторяет структуру и раскладку цели по правилу, разобранному у
-- revenue_daily_stage_rep и в docs/architecture/storage.md.
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_stage_rep ON CLUSTER clickstream_cluster
AS dm.purchase_vs_orders_rep
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY order_day
ORDER BY (order_day, order_id);
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_stage_dist ON CLUSTER clickstream_cluster
AS dm.purchase_vs_orders_stage_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'purchase_vs_orders_stage_rep',
cityHash64(order_id)
);
-- Трафик пересчитывается целиком: новая связь куки с пользователем меняет -- Трафик пересчитывается целиком: новая связь куки с пользователем меняет
-- прошлые дни. Версия запуска позволяет точному представлению скрыть строки -- прошлые дни. Версия запуска позволяет точному представлению скрыть строки
-- прежних пересчетов без TRUNCATE и без партиций. -- прежних пересчетов без TRUNCATE и без партиций.
@@ -76,3 +121,50 @@ ENGINE = Distributed(
'daily_traffic_rep', 'daily_traffic_rep',
cityHash64(report_date) cityHash64(report_date)
); );
-- Сводка качества хранит текущее состояние атомарной проверки одного дня.
-- История запусков остаётся в Airflow, поэтому версия строки не нужна:
-- следующий успешный прогон заменяет дневную партицию целиком.
CREATE TABLE IF NOT EXISTS dm.dq_summary_rep ON CLUSTER clickstream_cluster
(
data_date Date,
check_name LowCardinality(String),
reference_rows UInt64,
actual_rows UInt64,
failed_rows UInt64,
status LowCardinality(String),
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY data_date
ORDER BY (data_date, check_name);
-- Все строки одной проверки лежат на одном шарде. Таблица мала, а ключ
-- check_name делает раскладку донора и цели одинаковой без нового измерения.
CREATE TABLE IF NOT EXISTS dm.dq_summary_dist ON CLUSTER clickstream_cluster
AS dm.dq_summary_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'dq_summary_rep',
cityHash64(check_name)
);
-- Источник и цель REPLACE PARTITION обязаны совпадать по структуре, ключам и
-- политике хранения. Требование повторно сверено по документации ClickHouse
-- через Context7 27 августа 2026 года; AS не даёт формам разойтись.
CREATE TABLE IF NOT EXISTS dm.dq_summary_stage_rep ON CLUSTER clickstream_cluster
AS dm.dq_summary_rep
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY data_date
ORDER BY (data_date, check_name);
CREATE TABLE IF NOT EXISTS dm.dq_summary_stage_dist ON CLUSTER clickstream_cluster
AS dm.dq_summary_stage_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'dq_summary_stage_rep',
cityHash64(check_name)
);
+31
View File
@@ -15,6 +15,22 @@ SELECT
_load_ts _load_ts
FROM dm.revenue_daily_dist; FROM dm.revenue_daily_dist;
-- Непарные стороны остаются NULL. При join_use_nulls = 0 ClickHouse заполнил
-- бы их нулями и пустыми строками, скрыв отсутствие источника.
-- Служебные колонки называют запуск, который последним заменил день.
CREATE VIEW IF NOT EXISTS dm.purchase_vs_orders_v ON CLUSTER clickstream_cluster
AS
SELECT
order_day,
order_id,
declared_revenue,
items_total,
status,
mismatch_class,
_load_id,
_load_ts
FROM dm.purchase_vs_orders_dist;
-- Полный пересчет пишет новую версию каждого дня. FINAL оставляет текущие -- Полный пересчет пишет новую версию каждого дня. FINAL оставляет текущие
-- счетчики и прячет повторы запусков от потребителя. -- счетчики и прячет повторы запусков от потребителя.
CREATE VIEW IF NOT EXISTS dm.daily_traffic_v ON CLUSTER clickstream_cluster CREATE VIEW IF NOT EXISTS dm.daily_traffic_v ON CLUSTER clickstream_cluster
@@ -26,3 +42,18 @@ SELECT
_load_id, _load_id,
_load_ts _load_ts
FROM dm.daily_traffic_dist FINAL; FROM dm.daily_traffic_dist FINAL;
-- Сводка отвечает, какие атомарные договоры дня выполняются сейчас.
-- Явный список не пропускает в публичный договор будущие физические колонки.
CREATE VIEW IF NOT EXISTS dm.dq_summary_v ON CLUSTER clickstream_cluster
AS
SELECT
data_date,
check_name,
reference_rows,
actual_rows,
failed_rows,
status,
_load_id,
_load_ts
FROM dm.dq_summary_dist;
+71
View File
@@ -0,0 +1,71 @@
-- DM: сверка покупок трекера с текущими заказами бэкенда.
--
-- Обе стороны FULL OUTER JOIN — подзапросы. С голой левой Distributed
-- ClickHouse вернул бы непарный заказ по разу с каждого шарда; форма измерена
-- в docs/research/2026-08-22-distributed-join.md.
-- Выбор DROP PARTITION ALL вместо TRUNCATE разобран в соседнем
-- sql/dds/order_rebuild.sql.
ALTER TABLE dm.purchase_vs_orders_stage_rep ON CLUSTER clickstream_cluster
DROP PARTITION ALL;
INSERT INTO dm.purchase_vs_orders_stage_dist
(
order_day,
order_id,
declared_revenue,
items_total,
status,
mismatch_class,
_load_id,
_load_ts
)
SELECT
-- У парного заказа день берется у бэкенда; трекер дает его только
-- непарной покупке.
coalesce(order_date, purchase_day),
coalesce(purchase_order_id, backend_order_id),
declared_revenue,
items_total,
status,
multiIf(
backend_order_id IS NULL, 'awaiting_order',
status = 'cancelled', 'cancelled',
purchase_order_id IS NULL, 'lost_event',
purchase_count > 1, 'duplicate_event',
declared_revenue != items_total, 'amount_delta',
'match'
),
{load_id:String},
now64(3, 'UTC')
FROM
(
-- Контракт purchase несет один номер и одну объявленную сумму в массивах.
-- Бизнес-дубль повторяет оба значения, поэтому count хранится отдельно,
-- а сумма берется одна — складывать дубли значило бы выдумать дельту.
SELECT
purchase_id[1] AS purchase_order_id,
any(event_date) AS purchase_day,
any(purchase_revenue[1]) AS declared_revenue,
count() AS purchase_count
FROM dds.event_v
WHERE event_type = 'purchase'
AND event_date BETWEEN {first_day:Date} AND {last_day:Date}
GROUP BY purchase_order_id
) AS purchases
GLOBAL FULL OUTER JOIN
(
SELECT
order_id AS backend_order_id,
order_date,
items_total,
status
FROM dds.order_v
WHERE order_date BETWEEN {first_day:Date} AND {last_day:Date}
) AS orders ON purchase_order_id = backend_order_id
-- NULL обозначает отсутствующую сторону. При join_use_nulls = 0 ClickHouse
-- заполнил бы ее нулем или пустой строкой и скрыл отсутствие источника.
-- Вставка идет через ключ цели: источники разложены по другим ключам.
SETTINGS join_use_nulls = 1,
distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0;
+9
View File
@@ -0,0 +1,9 @@
-- DM: подмена одного дня сверки собранной партицией донора.
--
-- День для PARTITION берет purchase_vs_orders_scope.sql. Выбор Jinja,
-- атомарность и повтор разобраны в sql/dds/order_replace.sql.
--
-- Пустая партиция законна: если источник больше не показывает строк дня,
-- замена убирает этот день из витрины.
ALTER TABLE dm.purchase_vs_orders_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dm.purchase_vs_orders_stage_rep;
+62
View File
@@ -0,0 +1,62 @@
-- DM: объем пересборки сверки покупок с заказами.
--
-- У источников разные правые границы. Заказы называют изменяемое окно ярлыком
-- последнего принятого слепка. События заканчиваются последним прожитым днем,
-- который выводится из позиции мира. Одна граница другую не подменяет.
--
-- Левая граница возвращает не только пропуски, но и самый ранний
-- awaiting_order. Такой день уже есть в витрине, поэтому обычный поиск дыры
-- его не увидит. Иначе пакетный разгон длиннее K навсегда оставил бы прежний
-- хвост предварительным.
--
-- Файл заканчивается без точки с запятой: почему — sql/dds/order_scope.sql.
WITH
(SELECT min(event_date) FROM dds.event_v) AS first_event_day,
first_event_day + ({position:UInt16} - 1) AS last_lived_day,
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
(
SELECT min(toDate(toTimeZone(created_at, 'Europe/Samara')))
FROM ods.order_dist
WHERE snapshot_date = last_snapshot
) AS window_start,
(
SELECT max(toDate(toTimeZone(created_at, 'Europe/Samara')))
FROM ods.order_dist
WHERE snapshot_date = last_snapshot
) AS window_end,
(
SELECT maxOrNull(event_date)
FROM dds.event_v
WHERE event_type = 'purchase' AND event_date <= last_lived_day
) AS last_purchase_day,
(
SELECT minOrNull(day)
FROM (
SELECT order_date AS day
FROM dds.order_v
UNION DISTINCT
SELECT event_date AS day
FROM dds.event_v
WHERE event_type = 'purchase' AND event_date <= last_lived_day
)
WHERE day GLOBAL NOT IN (
SELECT order_day FROM dm.purchase_vs_orders_dist
)
) AS first_missing_day,
(
SELECT minOrNull(order_day)
FROM dm.purchase_vs_orders_dist
WHERE mismatch_class = 'awaiting_order'
) AS first_provisional_day
SELECT
least(
window_start,
coalesce(first_missing_day, window_start),
coalesce(first_provisional_day, window_start)
) AS first_day,
greatest(window_end, last_purchase_day) AS last_day
-- Без принятого слепка неизвестно окно заказов. Без готового DDS или прожитой
-- покупки донор был бы пуст, поэтому подменять партиции еще нельзя.
WHERE (SELECT count() FROM ods.order_dist) > 0
AND (SELECT count() FROM dds.order_v) > 0
AND last_purchase_day IS NOT NULL
+23
View File
@@ -0,0 +1,23 @@
-- DQ: стендовая сторона технической растяжки от описи мира.
--
-- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего
-- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону;
-- Python-задача сопоставляет ее с пятью счетчиками закрытого дня из описи.
SELECT
order_day AS data_date,
mismatch_class,
count() AS orders
FROM dm.purchase_vs_orders_v
WHERE mismatch_class IN (
'match',
'cancelled',
'lost_event',
'duplicate_event',
'amount_delta'
)
GROUP BY
data_date,
mismatch_class
ORDER BY
data_date,
mismatch_class
+71
View File
@@ -0,0 +1,71 @@
-- DQ: дневной трафик против прямого агрегата сессий и карты идентичностей.
--
-- Публичные представления скрывают физическую ко-локацию, поэтому GLOBAL
-- назван явно, как в самой витрине. Полное объединение не прячет день,
-- исчезнувший с любой стороны.
WITH
reference AS
(
SELECT
sessions.session_date AS report_date,
uniqExact(sessions.client_id) AS visitors,
uniqExactIf(identities.user_id, identities.user_id != 0) AS known_users
FROM dds.session_v AS sessions
GLOBAL LEFT JOIN dds.identity_map_v AS identities USING (client_id)
GROUP BY report_date
),
actual AS
(
SELECT
report_date,
count() AS physical_rows,
any(visitors) AS visitors,
any(known_users) AS known_users
FROM dm.daily_traffic_v
GROUP BY report_date
),
comparison AS
(
SELECT
coalesce(reference.report_date, actual.report_date) AS data_date,
toString(coalesce(reference.report_date, actual.report_date))
AS business_key,
isNotNull(reference.report_date) AS reference_present,
isNotNull(actual.report_date) AS actual_present,
toString(tuple(reference.visitors, reference.known_users))
AS reference_value,
concat(
toString(tuple(actual.visitors, actual.known_users)),
', rows=',
toString(actual.physical_rows)
) AS actual_value,
isNull(reference.report_date)
OR isNull(actual.report_date)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.visitors, actual.visitors)
OR NOT isNotDistinctFrom(reference.known_users, actual.known_users)
AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (report_date)
)
SELECT
data_date,
countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' key=',
business_key,
': эталон=',
if(reference_present, reference_value, 'нет ключа'),
', объект=',
if(actual_present, actual_value, 'нет ключа')
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1
+88
View File
@@ -0,0 +1,88 @@
-- DQ: дневная сводка брака против живого окна таблиц ошибок ODS.
--
-- Граница окна повторяет TTL источников; более старую историю сверять не с чем.
WITH
reference AS
(
SELECT
load_date,
source,
error_class,
count() AS error_rows
FROM
(
SELECT
toDate(_load_ts) AS load_date,
'event' AS source,
error_class
FROM ods.event_errors_dist
UNION ALL
SELECT
toDate(_load_ts) AS load_date,
'order' AS source,
error_class
FROM ods.order_errors_dist
)
WHERE load_date >= toDate(now() - INTERVAL 1 MONTH)
GROUP BY load_date, source, error_class
),
actual AS
(
SELECT
load_date,
source,
error_class,
count() AS physical_rows,
any(error_rows) AS error_rows
FROM dm.dq_errors_daily_v
WHERE load_date >= toDate(now() - INTERVAL 1 MONTH)
GROUP BY load_date, source, error_class
),
comparison AS
(
SELECT
coalesce(reference.load_date, actual.load_date) AS data_date,
concat(
coalesce(reference.source, actual.source),
'/',
coalesce(reference.error_class, actual.error_class)
) AS business_key,
isNotNull(reference.source) AS reference_present,
isNotNull(actual.source) AS actual_present,
reference.error_rows AS reference_value,
actual.error_rows AS actual_value,
actual.physical_rows AS physical_rows,
isNull(reference.source)
OR isNull(actual.source)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.error_rows, actual.error_rows)
AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (load_date, source, error_class)
)
SELECT
data_date,
countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' source/error_class=',
business_key,
': источник=',
if(reference_present, toString(reference_value), 'нет ключа'),
', витрина=',
if(
actual_present,
concat(toString(actual_value), ', rows=', toString(physical_rows)),
'нет ключа'
)
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1
+15
View File
@@ -0,0 +1,15 @@
-- DQ: область посуточной сверки обогащенных событий.
--
-- Объединение сторон оставляет видимым день, потерянный целиком.
SELECT day
FROM
(
SELECT event_date AS day
FROM dds.event_v
UNION ALL
SELECT event_date AS day
FROM dm.events_enriched_v
)
GROUP BY day
ORDER BY day
+125
View File
@@ -0,0 +1,125 @@
-- DQ: обогащенные события против прямого ответа DDS за один день.
--
-- День передает даг, чтобы широкая сверка не собирала всю историю в памяти.
WITH
reference AS
(
SELECT
event_date,
event_id,
tuple(
event_time,
toHour(event_time),
client_id,
event_type,
url,
path(url),
referer,
domain(referer),
title,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
device_category,
browser,
operating_system,
region_country,
region_city,
product_id,
product_name,
product_category,
product_price,
product_quantity,
product_event_type,
purchase_id,
purchase_revenue,
purchase_coupon
) AS value
FROM dds.event_v
WHERE event_date = {day:Date}
),
actual AS
(
SELECT
event_date,
event_id,
count() AS physical_rows,
any(tuple(
event_time,
event_hour,
client_id,
event_type,
url,
page_path,
referer,
referer_host,
title,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
device_category,
browser,
operating_system,
region_country,
region_city,
product_id,
product_name,
product_category,
product_price,
product_quantity,
product_event_type,
purchase_id,
purchase_revenue,
purchase_coupon
)) AS value
FROM dm.events_enriched_v
WHERE event_date = {day:Date}
GROUP BY event_date, event_id
),
comparison AS
(
SELECT
coalesce(reference.event_date, actual.event_date) AS data_date,
coalesce(reference.event_id, actual.event_id) AS business_key,
isNotNull(reference.event_id) AS reference_present,
isNotNull(actual.event_id) AS actual_present,
reference.value AS reference_value,
actual.value AS actual_value,
actual.physical_rows AS physical_rows,
isNull(reference.event_id)
OR isNull(actual.event_id)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.value, actual.value) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (event_date, event_id)
)
SELECT
data_date,
countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' event_id=',
toString(business_key),
': источник=',
if(reference_present, toString(reference_value), 'нет ключа'),
', витрина=',
if(
actual_present,
concat(toString(actual_value), ', rows=', toString(physical_rows)),
'нет ключа'
)
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1
+7
View File
@@ -0,0 +1,7 @@
-- DQ: общий донор очищается перед сборкой всех проверок.
--
-- Если одна сборка упадет, публикация не начнется. Следующий запуск удалит
-- неполный результат здесь и соберет донор заново.
ALTER TABLE dm.dq_summary_stage_rep ON CLUSTER clickstream_cluster
DROP PARTITION ALL;
+120
View File
@@ -0,0 +1,120 @@
-- DQ: строка сверки покупок и заказов против независимой сборки из DDS.
--
-- Проверяются все деловые поля. Счёт физических строк отдельно ловит дубль
-- ключа, даже если обе копии несут одинаковые значения.
WITH
purchases AS
(
SELECT
purchase_id[1] AS purchase_order_id,
any(event_date) AS purchase_day,
any(purchase_revenue[1]) AS declared_revenue,
count() AS purchase_count
FROM dds.event_v
WHERE event_type = 'purchase'
GROUP BY purchase_order_id
),
orders AS
(
SELECT
order_id AS backend_order_id,
order_date,
items_total,
status
FROM dds.order_v
),
reference AS
(
SELECT
coalesce(order_date, purchase_day) AS order_day,
coalesce(purchase_order_id, backend_order_id) AS order_id,
declared_revenue,
items_total,
status,
multiIf(
backend_order_id IS NULL, 'awaiting_order',
status = 'cancelled', 'cancelled',
purchase_order_id IS NULL, 'lost_event',
purchase_count > 1, 'duplicate_event',
declared_revenue != items_total, 'amount_delta',
'match'
) AS mismatch_class
FROM purchases
GLOBAL FULL OUTER JOIN orders
ON purchase_order_id = backend_order_id
),
actual AS
(
SELECT
order_day,
order_id,
count() AS physical_rows,
any(declared_revenue) AS declared_revenue,
any(items_total) AS items_total,
any(status) AS status,
any(mismatch_class) AS mismatch_class
FROM dm.purchase_vs_orders_v
GROUP BY
order_day,
order_id
),
comparison AS
(
SELECT
coalesce(reference.order_day, actual.order_day) AS data_date,
coalesce(reference.order_id, actual.order_id) AS business_key,
isNotNull(reference.order_id) AS reference_present,
isNotNull(actual.order_id) AS actual_present,
toString(tuple(
reference.declared_revenue,
reference.items_total,
reference.status,
reference.mismatch_class
)) AS reference_value,
concat(
toString(tuple(
actual.declared_revenue,
actual.items_total,
actual.status,
actual.mismatch_class
)),
', rows=',
toString(actual.physical_rows)
) AS actual_value,
isNull(reference.order_id)
OR isNull(actual.order_id)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(
reference.declared_revenue,
actual.declared_revenue
)
OR NOT isNotDistinctFrom(reference.items_total, actual.items_total)
OR NOT isNotDistinctFrom(reference.status, actual.status)
OR NOT isNotDistinctFrom(
reference.mismatch_class,
actual.mismatch_class
) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (order_day, order_id)
)
SELECT
data_date,
countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' key=',
business_key,
': эталон=',
if(reference_present, reference_value, 'нет ключа'),
', объект=',
if(actual_present, actual_value, 'нет ключа')
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1
+7
View File
@@ -0,0 +1,7 @@
-- DQ: публикация одного полностью собранного дня общей сводки.
--
-- День приходит из самого донора после успеха всех пяти сборок. REPLACE
-- PARTITION работает с локальными таблицами одинаковой структуры; это
-- ограничение сверено по документации ClickHouse 27 августа 2026 года.
ALTER TABLE dm.dq_summary_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dm.dq_summary_stage_rep;
+107
View File
@@ -0,0 +1,107 @@
-- DQ: дневная выручка против прямого агрегата заказов DDS.
--
-- День берётся из объединения сторон. Поэтому исчезнувший целиком день или
-- категория остаются видимы. Денежный расчет повторяет публичный договор
-- витрины и сохраняет Decimal до округления AOV.
WITH
reference AS
(
SELECT
report_date,
product_category,
orders,
units,
CAST(revenue, 'Decimal(18, 2)') AS revenue,
CAST(
round(divideDecimal(revenue, toDecimal128(orders, 0), 6), 2),
'Decimal(18, 2)'
) AS aov
FROM
(
SELECT
order_date AS report_date,
dictGet('dic.products', 'category', item_sku) AS product_category,
uniqExact(order_id) AS orders,
sum(item_quantity) AS units,
sum(item_price * item_quantity) AS revenue
FROM dds.order_v
ARRAY JOIN
item_sku,
item_quantity,
item_price
WHERE status = 'paid'
GROUP BY
report_date,
product_category
)
),
actual AS
(
SELECT
report_date,
product_category,
count() AS physical_rows,
any(orders) AS orders,
any(units) AS units,
any(revenue) AS revenue,
any(aov) AS aov
FROM dm.revenue_daily_v
GROUP BY
report_date,
product_category
),
comparison AS
(
SELECT
coalesce(reference.report_date, actual.report_date) AS data_date,
coalesce(reference.product_category, actual.product_category)
AS business_key,
isNotNull(reference.product_category) AS reference_present,
isNotNull(actual.product_category) AS actual_present,
toString(tuple(
reference.orders,
reference.units,
reference.revenue,
reference.aov
)) AS reference_value,
concat(
toString(tuple(
actual.orders,
actual.units,
actual.revenue,
actual.aov
)),
', rows=',
toString(actual.physical_rows)
) AS actual_value,
isNull(reference.product_category)
OR isNull(actual.product_category)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.orders, actual.orders)
OR NOT isNotDistinctFrom(reference.units, actual.units)
OR NOT isNotDistinctFrom(reference.revenue, actual.revenue)
OR NOT isNotDistinctFrom(reference.aov, actual.aov) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (report_date, product_category)
)
SELECT
data_date,
countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' key=',
business_key,
': эталон=',
if(reference_present, reference_value, 'нет ключа'),
', объект=',
if(actual_present, actual_value, 'нет ключа')
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1
+87
View File
@@ -0,0 +1,87 @@
-- DQ: дневной портрет сессий против прямого агрегата сессий DDS.
--
-- Полное объединение не прячет день или устройство, исчезнувшие с любой стороны.
WITH
reference AS
(
SELECT
session_date AS report_date,
device_category,
tuple(
count(),
uniqExact(client_id),
countIf(events_total = 1),
countIf(purchases > 0),
sum(duration_seconds),
sum(events_total),
sum(pageviews),
sum(cart_adds),
sum(purchases)
) AS value
FROM dds.session_v
GROUP BY report_date, device_category
),
actual AS
(
SELECT
report_date,
device_category,
count() AS physical_rows,
any(tuple(
sessions,
visitors,
bounce_sessions,
purchase_sessions,
duration_seconds_total,
events_total,
pageviews,
cart_adds,
purchases
)) AS value
FROM dm.session_overview_v
GROUP BY report_date, device_category
),
comparison AS
(
SELECT
coalesce(reference.report_date, actual.report_date) AS data_date,
coalesce(reference.device_category, actual.device_category)
AS business_key,
isNotNull(reference.device_category) AS reference_present,
isNotNull(actual.device_category) AS actual_present,
reference.value AS reference_value,
actual.value AS actual_value,
actual.physical_rows AS physical_rows,
isNull(reference.device_category)
OR isNull(actual.device_category)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.value, actual.value) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (report_date, device_category)
)
SELECT
data_date,
countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' device_category=',
business_key,
': источник=',
if(reference_present, toString(reference_value), 'нет ключа'),
', витрина=',
if(
actual_present,
concat(toString(actual_value), ', rows=', toString(physical_rows)),
'нет ключа'
)
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1
+65
View File
@@ -0,0 +1,65 @@
-- DQ: эталонная сессия Метрики против сессии, нарезанной слоем DDS.
--
-- Сопоставление идет по куке внутри завершенного дня: лишняя сессия одной
-- куки не скрывает потерянную сессию другой. Результат содержит итог дня.
WITH
(SELECT min(EventDate) FROM ods.event_v) AS first_source_day,
first_source_day + ({position:UInt16} - 1) AS last_lived_day,
reference AS
(
SELECT
EventDate AS data_date,
ClientID AS client_id,
uniqExact(VisitID) AS sessions
FROM ods.event_v
WHERE EventDate <= last_lived_day
GROUP BY
data_date,
client_id
),
actual AS
(
SELECT
session_date AS data_date,
client_id,
count() AS sessions
FROM dds.session_v
WHERE session_date <= last_lived_day
GROUP BY
data_date,
client_id
),
comparison AS
(
SELECT
coalesce(reference.data_date, actual.data_date) AS data_date,
toString(coalesce(reference.client_id, actual.client_id)) AS business_key,
isNotNull(reference.client_id) AS reference_present,
isNotNull(actual.client_id) AS actual_present,
toString(reference.sessions) AS reference_value,
toString(actual.sessions) AS actual_value,
NOT isNotDistinctFrom(reference.sessions, actual.sessions) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (data_date, client_id)
)
SELECT
data_date,
countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' key=',
business_key,
': эталон=',
if(reference_present, reference_value, 'нет ключа'),
', объект=',
if(actual_present, actual_value, 'нет ключа')
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1
+71
View File
@@ -0,0 +1,71 @@
-- DQ: дневная популярность страниц против прямого агрегата событий DDS.
--
-- Полное объединение не прячет страницу, исчезнувшую с любой стороны.
WITH
reference AS
(
SELECT
event_date AS report_date,
path(url) AS page_path,
tuple(
countIf(event_type = 'pageview'),
uniqExact(client_id),
countIf(event_type = 'add_to_cart'),
countIf(event_type = 'purchase')
) AS value
FROM dds.event_v
GROUP BY report_date, page_path
),
actual AS
(
SELECT
report_date,
page_path,
count() AS physical_rows,
any(tuple(pageviews, visitors, cart_adds, purchases)) AS value
FROM dm.top_pages_daily_v
GROUP BY report_date, page_path
),
comparison AS
(
SELECT
coalesce(reference.report_date, actual.report_date) AS data_date,
coalesce(reference.page_path, actual.page_path) AS business_key,
isNotNull(reference.page_path) AS reference_present,
isNotNull(actual.page_path) AS actual_present,
reference.value AS reference_value,
actual.value AS actual_value,
actual.physical_rows AS physical_rows,
isNull(reference.page_path)
OR isNull(actual.page_path)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.value, actual.value) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (report_date, page_path)
)
SELECT
data_date,
countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' page_path=',
business_key,
': источник=',
if(reference_present, toString(reference_value), 'нет ключа'),
', витрина=',
if(
actual_present,
concat(toString(actual_value), ', rows=', toString(physical_rows)),
'нет ключа'
)
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1
+88
View File
@@ -0,0 +1,88 @@
-- DQ: эффективность UTM-меток против прямого агрегата событий DDS.
--
-- Полное объединение не прячет набор меток, исчезнувший с любой стороны.
WITH
reference AS
(
SELECT
event_date AS report_date,
utm_source,
utm_medium,
utm_campaign,
tuple(
uniqExact(client_id),
countIf(event_type = 'pageview'),
countIf(event_type = 'add_to_cart'),
countIf(event_type = 'purchase'),
sumIf(purchase_revenue[1], event_type = 'purchase')
) AS value
FROM dds.event_v
GROUP BY report_date, utm_source, utm_medium, utm_campaign
),
actual AS
(
SELECT
report_date,
utm_source,
utm_medium,
utm_campaign,
count() AS physical_rows,
any(tuple(
visitors,
pageviews,
add_to_carts,
purchases,
declared_revenue
)) AS value
FROM dm.utm_effectiveness_v
GROUP BY report_date, utm_source, utm_medium, utm_campaign
),
comparison AS
(
SELECT
coalesce(reference.report_date, actual.report_date) AS data_date,
concat(
'utm_source=', coalesce(reference.utm_source, actual.utm_source),
', utm_medium=', coalesce(reference.utm_medium, actual.utm_medium),
', utm_campaign=',
coalesce(reference.utm_campaign, actual.utm_campaign)
) AS business_key,
isNotNull(reference.utm_source) AS reference_present,
isNotNull(actual.utm_source) AS actual_present,
reference.value AS reference_value,
actual.value AS actual_value,
actual.physical_rows AS physical_rows,
isNull(reference.utm_source)
OR isNull(actual.utm_source)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.value, actual.value) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual
USING (report_date, utm_source, utm_medium, utm_campaign)
)
SELECT
data_date,
countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' ',
business_key,
': источник=',
if(reference_present, toString(reference_value), 'нет ключа'),
', витрина=',
if(
actual_present,
concat(toString(actual_value), ', rows=', toString(physical_rows)),
'нет ключа'
)
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1