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
76 changed files with 4583 additions and 823 deletions

No files matched your search

+4
View File
@@ -157,6 +157,10 @@ Airflow) и названия из кода. Если для понятия ес
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
истории, а текущее описание живого формата — и собирается из кода, а не
пишется руками.
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.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` — очистка закончена, а начальная генерация ещё не завершена. Работники
начинают играть только с позиции `8` и больше.
В форме запуска можно выбрать только число дней. Первый день работник берет из
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
позицию работник пишет в журнал шага `remember_played`.
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
целиком — [ADR 0009](docs/adr/0009-world-control.md).
+6 -4
View File
@@ -48,10 +48,10 @@ x-kafka-target: &kafka-target
x-world-starting-days: &world-starting-days "8"
# Общие образы Airflow и Superset собирают службы инициализации. Причина
# отдельного build: ADR 0001.
x-airflow-common: &airflow-common
image: clickstream-airflow:local
build:
context: ./infra/airflow
environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__PARALLELISM: "4"
@@ -105,8 +105,6 @@ x-generator-common: &generator-common
x-superset-common: &superset-common
image: clickstream-superset:local
build:
context: ./infra/superset
environment:
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
@@ -257,6 +255,8 @@ services:
airflow-init:
<<: *airflow-common
build:
context: ./infra/airflow
user: "0:0"
restart: "no"
depends_on:
@@ -340,6 +340,8 @@ services:
superset-init:
<<: *superset-common
build:
context: ./infra/superset
restart: "no"
healthcheck:
disable: true
+8 -2
View File
@@ -151,11 +151,17 @@ def build_classes_vs_inventory() -> list[str]:
raise AirflowException("classes_vs_inventory: в описи нет закрытых дней")
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
actual = {
# 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)
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 = []
+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
import datetime
import logging
import os
from airflow.exceptions import AirflowException
@@ -141,7 +142,19 @@ def _ingest_orders() -> TriggerDagRunOperator:
tags=TAGS,
params={
"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
def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранным дням и только по успеху."""
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()
played = _generator(
@@ -196,12 +215,15 @@ def world_live_day():
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
реальные минуты, и суточная волна разворачивается на глазах. Запускается
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
Следующий день работник берет из `world_position`. Логическая дата прогона
на выбор дня не влияет.
"""
@task
def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранному дню и только по успеху."""
Variable.set(WORLD_POSITION, str(first_day + 1))
logging.info("сыгран день %s; новая позиция — %s", first_day, first_day + 1)
first_day = first_unplayed_day()
played = _generator("play_day", ["live", "--day", PLAYED_DAY])
+25 -23
View File
@@ -6,92 +6,94 @@
{
"day": 0,
"date": "2026-06-01",
"events": 50626,
"sha256": "81ad7f402bdd4c8b56425f6439a024b9996e5ca95dd207d444290d20a70ed407",
"events": 50628,
"sha256": "d09704ed99e45824e451e2f6bbbbd5c547df382f7a582699955010f3807eb0ee",
"orders": {
"match": 197,
"match": 192,
"cancelled": 20,
"lost_event": 3,
"duplicate_event": 2,
"amount_delta": 6
}
},
{
"day": 1,
"date": "2026-06-02",
"events": 49722,
"sha256": "5f7aa0301832008db612eff05899b1210af958c888ac2e9e89f4b7a552569eff"
"events": 49727,
"sha256": "8005d2934eb353478e35331d7ae7ae6876c7934b2d9032fde92cf6ce86d6460f"
},
{
"day": 2,
"date": "2026-06-03",
"events": 52361,
"sha256": "10ac9ce65ca6dc171689edf78ef5cd785e8c8a80e54d236bf2b9d6dbf6997070"
"events": 52363,
"sha256": "e13f6f3f3977386a94d0e63b6849675cb5d2486a277d559fd415923cfc82e53a"
},
{
"day": 3,
"date": "2026-06-04",
"events": 53157,
"sha256": "7bf4deadd8f69a6c479fceabfe2d1e8ea8db76919b10a8b9e780006708da31c3"
"events": 53159,
"sha256": "91c713add62222adc3f89c04e6cc087d8c44e2385d14d0464e2c016b6952b9fd"
},
{
"day": 4,
"date": "2026-06-05",
"events": 50078,
"sha256": "b9e758fb72f937aa5ad1f2e90dee5ec147b16cfd89e49e51a00582f909458c29"
"events": 50087,
"sha256": "b7fc27d73fc157a48bece0189c7983f6bc672f052e0703cd799b9774da4b904e"
},
{
"day": 5,
"date": "2026-06-06",
"events": 47164,
"sha256": "f08a9f9a439f1da9e4ad8d94b9f65b7f3329256e41446d22d900cc4825f2480c"
"events": 47169,
"sha256": "82cb8017d53d6dee8438d3683b7a901f1ace139f3b8ae836215722bb580fe207"
},
{
"day": 6,
"date": "2026-06-07",
"events": 48683,
"sha256": "7e45d9dd59c902067fa6847d813669b1ab9a4ee88d2f192382577a5ed8d590a7"
"events": 48686,
"sha256": "b50ad0fcc7d1ea0c637cfcba98e5c2b55d65215cffa3693bc77edeaa83e6c783"
},
{
"day": 7,
"date": "2026-06-08",
"events": 49394,
"sha256": "286ccc2451847ac8de215b5e902c328371021ad5502bd4afca0494b6f313aef7"
"sha256": "358d51b36221d822960237702fa6327fb663a57cf49cc4415a859b16e5aa05a2"
}
],
"snapshots": [
{
"day": 0,
"date": "2026-06-01",
"sha256": "6c3e3c1c8689ec23f53f0953db1b941e6f010e9e56a74d8d017f40195d3dec17"
"sha256": "93b39f261cbbe894402ebe0525460fed1809e5712639be4b73b79dac2b39f5bd"
},
{
"day": 1,
"date": "2026-06-02",
"sha256": "9acb523cfd47f3d97d55122025852982ba5a1d8362036308ce72a4d9fb767f43"
"sha256": "4fef382cd84868ccfb61a9d8610cdd26f9a749bbae8b20800c45e520e9ce414e"
},
{
"day": 2,
"date": "2026-06-03",
"sha256": "b81482491c4c0b874ea84d5f3f05144100c906dacc059c068b331df258b97172"
"sha256": "caf9ed2e77d2fcc99066a548dcba60f61f1ff0d4402de31457a06f23fd2d8306"
},
{
"day": 3,
"date": "2026-06-04",
"sha256": "9d1ab326c4bf8b78c9fcaf7015d8029f9ad4cff5558b89c730eb428d175cc9ee"
"sha256": "cdb9e4e00b57c04985866b5a851a8e2f23b3e9205ea131c7a5076672ee8b5669"
},
{
"day": 4,
"date": "2026-06-05",
"sha256": "0e30d0e192e121121003c0cc9aaa55e24924afb983caff15ab105705fbf2bd00"
"sha256": "c58d2c20fe3b4cee2973f34c322090dbb66d3793bfaab0337c22b53fb5a3ec7f"
},
{
"day": 5,
"date": "2026-06-06",
"sha256": "9a2ad812fcff9a40a988f24f8aa4fc7a379873d173a95ddacd438592aed0bfb5"
"sha256": "bbf28b1d375e4815695c72a5915a22a3c9b36195b97ec4cc7c739466dbe70b53"
},
{
"day": 6,
"date": "2026-06-07",
"sha256": "e1706a27ff55de489a505489148c7a959329d95ad6597d92d3f78505f67bb221"
"sha256": "8ca077576bcf014c0d492c7d7190e38c0e46ec95951ad840df93eb75ee5a00d3"
}
]
}
+7
View File
@@ -16,6 +16,13 @@
возвращает единицу — поведение проверено при исполнении #37. Зависимость
включает результат подготовки в общий граф ожидания.
У каждого общего локального образа один блок `build`. Он находится у службы
инициализации. Остальные службы зависят от неё и используют тот же `image`.
`docker compose up --build` завершает сборку до запуска контейнеров, поэтому
одного блока достаточно. В Buildx 0.30.1 одинаковые блоки у нескольких служб
запускают параллельный экспорт одного тега. Иногда сборка завершается ошибкой
`image already exists`.
Для входа выбран SimpleAuthManager. У него нет команды создания пользователя,
поэтому `airflow-init` записывает пароль администратора из окружения в
JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит
+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.
+6 -6
View File
@@ -35,10 +35,9 @@
ломают.
`session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка:
витрины сессий читают `dds.session_v`, их зерно совпадает с зерном модели, и в
соединениях id не участвует — события его не несут, мост к заказам живёт на
событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со стендовой и
назвать расхождение одним числом) и разбор DQ-расхождений.
в соединениях он не участвует — события его не несут, мост к заказам живёт
на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со
стендовой и назвать расхождение одним числом) и разбор DQ-расхождений.
## Состав строки
@@ -110,8 +109,9 @@
## Читатели
- Витрина обзора сессий `session_overview_v`: длительности, глубина,
вход/выход; читает `dds.session_v`, зерно совпадает (#135).
- Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
в ее [ТЗ](../../labs/session-overview.md) (#135).
- DQ-сверка с эталоном (см. выше).
- Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда,
потом сравнивает — с эталоном и со стендовой.
+17 -19
View File
@@ -39,14 +39,16 @@
образцом. Разминка — представление `events_enriched_v` (зерно события),
затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`,
последней — самая содержательная `utm_effectiveness` с уроком `declared_*`.
Построенная витрина встаёт в конвейер руками менти: финальный пункт
каждого ТЗ — добавить задачу своей витрины в `dm_transform`. До этого
Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
наравне со стендовыми.
наравне со стендовыми. У разминочного представления такого пункта нет:
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
от выполненных домашек. Расширение дашборда визуализациями по своим
витринам — отдельная домашка после этапа 8.
от выполненных домашек. Свой дашборд по витринам менти —
[необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
стендового дашборда — в [доке зоны Superset](superset.md).
## Ритм пересборки
@@ -110,7 +112,7 @@
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную
сумму, а отдельный счет событий оставляет будущий класс `duplicate_event`
сумму, а отдельный счет событий оставляет класс `duplicate_event`
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
@@ -175,8 +177,8 @@ Distributed-таблица вернула бы каждый непарный з
`dds.session_v`;
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
`orders` в описи мира, объект — число заказов класса в
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled` и
`amount_delta` только для покрытых описью закрытых дней;
`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` внутри дня. Все деловые
@@ -212,22 +214,18 @@ business_key`.
## Даг самопроверки
Домашки контролирует отдельный даг без расписания: менти запускает его
рукой. Задача витрины красная, пока витрины нет или счёт не сходится, и
зелёная, когда построено верно; сообщение об ошибке говорит, что не так и
куда смотреть. Проверки — инварианты против DDS-источников, золотые
константы не прибиваются. В `dq_summary` даг не пишет: он инструмент лабы,
а не история качества конвейера.
Домашки контролирует `homework_check` — отдельный даг без расписания: менти
запускает его рукой. По задаче на каждое из
[пяти заданий по витринам](../labs/README.md).
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
лабы, а не история качества конвейера.
Приём проверен на соседнем учебном стенде Airflow + Greenplum:
валидационный даг с группами проверок по слоям давал менти обратную связь
по каждому шагу домашки, не трогая рабочий конвейер.
## Остается нарезке
- Имя и состав дага самопроверки домашних заданий.
- Тексты учебных заданий и точный список проверок учебного контура.
## Отклонённые варианты
- **Весь слой представлениями.** Дёшево и всегда свежо, но главный
+3 -3
View File
@@ -137,9 +137,9 @@ ODS и ещё не собранный в DDS, затем при необходи
## Не входит
- Партиционная переобработка — переиграть произвольный прошлый день или
диапазон, включая форму необязательных параметров того же дага, — этап 5
(#7); туда же стык #111 про дату модельного мира и `logical_date`.
- Инкрементальный ETL вместо пересборки — техдолг #2.
диапазон, включая форму необязательных параметров того же дага.
- Инкрементальный ETL вместо пересборки отклонен (#2, wontfix). Идея отдельной
лабы на эту тему — #149.
- Состав витрин `dm_transform` и проверок `dq_check` —
[дока слоя 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` |
| `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
+2 -10
View File
@@ -27,8 +27,8 @@
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
[судьба заказа](fate.md).
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
заказная и событийная стороны; броски независимы, пересечения выходят
арифметикой, приоритет классов работает по-настоящему —
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
[классы расхождений и опоздание](fate.md).
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
каждого слепка и счётчики наблюдаемых классов —
@@ -51,14 +51,6 @@
## Открытые решения
- **Имя подпотока событийной стороны** — вместе с её содержимым на этапе 6;
из мёртвых имён никто не бросает, переименование ничего не сдвигает (#72).
Заказная сторона уже названа — подпоток `ORDERS` ([судьба
заказа](fate.md)).
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
доли классов, стоимость доставки — калибровка при реализации; финальная
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
потребуют только числа, не устройство.
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
остаётся в тумане карты #69; естественное место разговора — этап 4.
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
+30 -25
View File
@@ -16,11 +16,10 @@
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
компонент называет часть мира; новые стороны занимают те же позиции.
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
событийная получит имя вместе с содержимым на этапе 6, до тех пор позиция 3
занята мёртвым именем прежней нарезки. Отклонено: *один компонент на всю
судьбу* — правка событийной механики молча меняла бы байты слепка;
*компонент на класс* — пять имён под ручки калибровки, которые крутятся
разом.
событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
событийной механики молча меняла бы байты слепка; *компонент на класс* —
пять имён под ручки калибровки, которые крутятся разом.
**Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
@@ -56,8 +55,11 @@
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
она делится между двумя дорогами — строки таблицы исходов. Точные числа —
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим.
она делится между двумя дорогами — строки таблицы исходов. Замер на
каноническом мире (28 дней, 2026-08-30, docs/architecture/testing.md, «Что
проверено»): отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12%
отменен неоплаченным; в пределах ориентира. Проверок вида «отмен от 4 до 6
процентов» не заводим.
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии
@@ -108,12 +110,13 @@
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID`
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита,
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с
покупкой, по тому же правилу, что у подтверждения с торговым хвостом.
один в один — посчитал наивно, удвоил выручку. Дубль случается только там,
где пауза до следующего визита куки длиннее таймаута и задержки дубля вместе:
иначе сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон,
ради которого `VisitID` в потоке лежит. Задержка дубля —
секунды-минуты, короче таймаута визита, поэтому `VisitID` тот же. Полночь
режет дубль парой — просмотр вместе с покупкой, по тому же правилу, что у
подтверждения с торговым хвостом.
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
потеря перестаёт быть видна со стороны трекера.
@@ -127,18 +130,20 @@
подделывается — строка создана, когда заказ родился, — но в слепках дней
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка — таблица
весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть «D+1/D+2»
мастер-спеки. Меряется она в днях снятия слепка, а не отправки: иначе сдвиг
отправки удвоился бы, и обещанные D+1/D+2 стали бы D+2/D+3. Дальше таблица не
идёт: заказ с δ = 6 приехал бы ровно в одном слепке, и обещание «пропущенный
день ничего не ломает» на нём перестало бы быть верным; при δ ≤ 2 у всякого
заказа слепков не меньше пяти. Легенда: заказ ушёл в ручную обработку и попал
в выгрузку позже. Отклонено: *сдвиг `created_at`* — подделка аудита источника:
день создания строки разошёлся бы с днём покупки, чьё равенство держит
синхронная модель ([откуда берётся заказ](snapshot.md)), заказ уехал бы в
чужую партицию, и «выручка дня D» перестала бы отвечать покупкам дня D —
сломалась бы та самая сверка, ради которой всё строится.
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Таблица задержки
состоит из трёх строк: δ = 0/1/2 с весами 90/5/5; 1 и 2 — это D+1 и D+2
мастер-спеки. Бросок идёт последним в подпотоке `ORDERS`; затем δ назначенного
планом заказа обнуляется. Меряется задержка в днях снятия слепка, а не
отправки: иначе сдвиг отправки удвоился бы, и обещанные D+1/D+2 стали бы
D+2/D+3. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно в одном
слепке, и обещание «пропущенный день ничего не ломает» на нём перестало бы
быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Отклонено:
*сдвиг `created_at`* — подделка аудита источника: день создания строки
разошёлся бы с днём покупки, чьё равенство держит синхронная модель
([откуда берётся заказ](snapshot.md)), заказ уехал бы в чужую партицию,
и «выручка дня D» перестала бы отвечать покупкам дня D — сломалась бы та
самая сверка, ради которой всё строится.
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
+12 -9
View File
@@ -18,9 +18,10 @@
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
выбрасыванием события после присвоения номера
([классы расхождений](fate.md));
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`,
читаемый номер «день и порядковый номер покупки» ([спека
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все
- обе проекции используют один номер заказа: `order_id` = клиентский
`purchaseID`;
читаемый номер «день и порядковый номер покупки» описан в
[ADR 0017](../../adr/0017-order-numbering.md); нумеруются все
покупки, дошедшие до потока дня, — до всяких потерь;
- скидка заказа выводится из промокода события по таблице «код → скидка» —
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
@@ -59,12 +60,14 @@
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
топиком `hits`* — убивает два режима приёма.
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и
собирается переигровкой этих семи дней: заказы дня — производная всей воронки
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок;
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве.
**Сборка окна.** Слепок дня D берет заказы, рожденные в дни D−6…D, а несет из
них те, что уже попали в выгрузку: механика опоздания живет в
[судьбе заказа](fate.md). Окно собирается переигровкой этих семи дней: заказы
дня — производная всей воронки дня, дешёвого пути к ним нет. Цена — семь
проигрышей дня (~14 с) на слепок; у начала оси окно усекается само. Отклонено:
*кэш заказов на томе* — состояние между прогонами; *окно держит хранилище* —
топик перестаёт нести слепок; *K = 1* — это страховочный срез 1 мастер-спеки,
он в резерве.
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
+1 -1
View File
@@ -296,7 +296,7 @@ UTC+4), и пересчёт идёт один раз при наполнении
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
генератором и лежит колонкой, так что суточные срезы группируются по ней и
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9.
расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md).
## Путь реплицированных таблиц в keeper
+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 августа
2026 года, `make check-clickhouse` — 19 августа, корневой `make lint` —
9 августа, `make test` — 7 августа; остальные стоят с замера 6 августа.
`make smoke` и `make check-services` перемерены 28 августа 2026 года,
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с
замера 6 августа.
| Цель | Откуда | Что утверждает | Стенд | Цена |
|---|---|---|---|---|
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 17 с |
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
| `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 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` две, и это не опечатка.** Одноимённые цели за
разными дверями охватывают разное: корневая не видит ни одного файла
@@ -120,10 +121,11 @@ ClickHouse отвечает сразу.
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
дорогая. Правки дагов и 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 минут.
На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался
`etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли
+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()` должен входить в ТЗ каждого вычитания отдельной строкой.
+30 -16
View File
@@ -265,7 +265,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
| | Расхождение | Механика в генераторе | Ориентир доли |
|---|---|---|---|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
| B | Потерянное событие | заказ есть, `purchase` не доехал | бросок 3%, наблюдаемых ~1,8%: часть маячков защищена парами склейки, часть уходит в отмену приоритетом |
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% |
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
@@ -274,8 +274,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
ориентир ~10%. Точные доли зафиксированы замером на каноническом мире
(28 дней, 2026-08-30, #194) — запись в «Что проверено»
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
только по дням с закрытым окном (раздел 8).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
@@ -512,8 +513,14 @@ Kafka день переигрывается генератором заново:
`dds.identity_map_v`: опыт на ClickHouse 26.3 показал, что публичные `_v`
скрывают ко-локацию от локальной подстановки.
- `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`,
`dq_errors_daily_v` — переезжают на новую модель без смены роли: источник —
`dds.event_v`, не `ods.event`.
`dq_errors_daily_v` — переезжают на новую модель без смены роли. Источник
и зерно у двух из них меняет сама новая модель, и это зафиксировано ТЗ
домашек ([`docs/labs/`](../labs/README.md)):
`session_overview_v` читает `dds.session_v` и поднимается на зерно «день ×
срез», потому что визит стал сущностью DDS и витрина его зерна была бы
копией сессий; `dq_errors_daily_v` читает таблицы `ods.*_errors`, у
которых в DDS представительства нет. Остальные две читают `dds.event_v`,
а не `ods.event`.
- `dm.dq_summary` хранит текущее качество дня и обновляется партиционной
заменой (политика «без TRUNCATE»).
@@ -526,10 +533,11 @@ Kafka день переигрывается генератором заново:
## 8. Эталонный мир и опись
В git хранится только опись эталонного мира; сам снимок (14 модельных
дней) генерируется на месте — при `make up` и при проверках (решение
развилки «Производительность» карты #26, подробности —
[спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт
В git хранится только опись эталонного мира; сам снимок генерируется на
месте — при `make up` и при проверках (решение развилки «Производительность»
карты #26, подробности — [спека генератора](2026-08-01-generator.md),
раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней
снято на этапе 7. Опись несёт
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
@@ -549,8 +557,8 @@ Kafka день переигрывается генератором заново:
названного читателя в опись не кладётся
([что хранит опись](../architecture/orders/inventory.md)).
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
при пересборке.
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
[карты проверок](../architecture/testing.md).
## 9. Оценка объёма исполнения
@@ -612,8 +620,8 @@ v2 стартует пустым, поэтому объём ниже — это
выручка, сверка A+C.
5. Airflow: `etl_pipeline` (партиционная переобработка).
6. Расхождения B+D и опоздания; счётчики описи.
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация
на amd64 и arm64.
7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация
снята (спека генератора, раздел 5).
8. Superset-дашборд v2.
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
и границы — ADR 0002. Этап идёт не подряд, см. ниже.
@@ -650,9 +658,15 @@ ADR 0002 с приёмочными совпадают почти целиком.
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
переносится. Спека даёт будущим лабам только опорные точки — контрольные
числа описи (сверка, идентичность). Явное следствие: после этапа 9
стенд работает, но учебного пути на нём ещё нет.
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и
конвейер, в который она встает, проектируются вместе. Сюда же —
необязательная лаба «свой дашборд в Superset» (#197): она стоит на
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как
остальные лабы пока на уровне идей. Единый стиль блока
домашек — за работой учебного пути. Спека даёт будущим лабам только
опорные точки — контрольные числа описи (сверка, идентичность). Явное
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет.
- Инкрементальный ETL (#8) — свой issue.
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
- 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:
uv run pytest
# --- Пример из путеводителя ---
demo:
uv run python -m clickstream_generator.demo
# --- Сборка из кода ---
docs:
+6 -25
View File
@@ -13,31 +13,11 @@
## Как это работает
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не
> выверен: пишет его тот, кто держит устройство генератора в голове, а это
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48.
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
история для него не нужна и на него не влияет.
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
значит сдвинуть все броски после него, а с ними и весь мир: события того же
дня станут другими, счётчики канонического мира разойдутся с описью, и
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
`plan.cohort` брошены последними, а личность человека приписана уже за ними:
куки, пары и паспорта остались теми же до байта.
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
распределения numpy расходятся между версиями и архитектурами, а обещано
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
`rng.choice` с вероятностями.
Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
а день-функция проживает один день без накопленного состояния. Код этой
цепочки, ее схема и запуск D0 собраны в
[путеводителе](../docs/architecture/generator/README.md).
## Что где лежит
@@ -104,6 +84,7 @@ D0 живёт предыстория, поэтому любой день соб
- `make test` — тесты генератора;
- `make lint` — ruff: проверка и формат;
- `make typecheck` — ty: проверка типов;
- `make demo` — пройти стадии канонического D0 на одном экране;
- `make docs` — пересобрать описание выгрузки;
- `make inventory` — пересобрать опись мира.
+164 -11
View File
@@ -53,6 +53,7 @@
"""
from dataclasses import dataclass
from enum import IntEnum
from typing import Any
import numpy as np
@@ -160,6 +161,14 @@ class Purchases:
return self.person_id.size
class EventOutcome(IntEnum):
"""Что случилось с маячком purchase после создания заказа."""
DELIVERED = 0
LOST = 1
DUPLICATED = 2
@dataclass(frozen=True, slots=True)
class _Events:
"""Строки одного вида торговых событий, готовые встать в поток.
@@ -177,9 +186,18 @@ class _Events:
basket: NDArray[np.int64]
# Что торговая половина отдаёт дню: поток целиком — колонки, страницы и
# товары — и покупки дня вторым выходом.
Woven = tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], Purchases]
@dataclass(frozen=True, slots=True)
class Woven:
"""Что торговая половина отдает дню после вплетения событий."""
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(
@@ -191,6 +209,8 @@ _NO_PURCHASES = Purchases(
revenue=np.empty(0, dtype=np.int64),
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(
@@ -200,6 +220,7 @@ def weave(
page: NDArray[np.uint8],
product: NDArray[np.int64],
person: NDArray[np.uint64],
assigned_order: NDArray[np.bool_],
) -> Woven:
"""Вплетает торговые события в поток и отдаёт поток и покупки дня.
@@ -207,14 +228,21 @@ def weave(
события встают между ними, и поток пересобирается одним порядком. Второй
выход — покупки дня: то же самое, чем они уехали в события.
`person` — человек за кукой каждой строки, выровненный по входящему
потоку: личность приходит от плана состава, а не выводится из трекера
(docs/architecture/orders/identity.md).
`person` и `assigned_order` выровнены по входящему потоку. Оба
признака приходят из плана состава: личность дает заказу `user_id`,
а назначение защищает мост склейки от потери purchase.
"""
rng = day_stream(seed, day, Component.COMMERCE)
baskets = _baskets(rng, page, product, columns["VisitID"])
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()
draws = _draws(rng, baskets)
@@ -225,8 +253,20 @@ def weave(
_order_events(columns, baskets, goods, draws, revenue, coupons),
)
columns, page, product, basket = _stream(rng, columns, page, product, events)
purchases = _purchases(columns, basket, baskets, draws, revenue, coupons, person)
return columns, page, product, purchases
purchases, assigned = _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:
@@ -653,7 +693,8 @@ def _purchases(
revenue: NDArray[np.int64],
coupons: NDArray[np.object_],
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)
mine = basket[here]
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()),
person_id=person[baskets.confirmation[mine]],
product=tuple(baskets.product[group] for group in bought),
@@ -673,6 +714,118 @@ def _purchases(
revenue=revenue[mine],
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:
+10 -5
View File
@@ -100,6 +100,8 @@ class Day:
page: NDArray[np.uint8]
product: NDArray[np.int64]
orders: orders.Orders
# Событийный исход каждого заказа, в том же порядке.
purchase_outcome: NDArray[np.int8]
def __len__(self) -> int:
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)
columns, page, product, purchases = commerce.weave(
assigned_order = np.repeat(visits.ordering, visits.pages)
woven = commerce.weave(
seed,
day,
{name: value[order] for name, value in columns.items()},
page[alive][order],
product[alive][order],
person[alive][order],
assigned_order[alive][order],
)
return Day(
day=day,
columns=columns,
page=page,
product=product,
orders=orders.of_day(seed, day, purchases),
columns=woven.columns,
page=woven.page,
product=woven.product,
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`. Понедельник по
понедельник — полная неделя с выходными и первый замкнутый цикл окна 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]:
"""Заказы дня по итоговому классу: отмена перевешивает дельту суммы."""
"""Заказы дня по итоговому классу по приоритету спеки."""
purchase = today.columns["EventType"] == commerce.PURCHASE
declared = today.columns["purchaseRevenue"][purchase]
counts = {"match": 0, "cancelled": 0, "amount_delta": 0}
declared = {
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(
today.orders.outcome, today.orders.items_total, declared, strict=True
for order_id, order_outcome, event_outcome, items_total in zip(
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
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
else:
counts["match"] += 1
+31 -6
View File
@@ -24,11 +24,13 @@
одной доли перебрасывала бы весь подпоток после себя
(docs/architecture/orders/fate.md).
**Судьба заказа — исход и его моменты**: из окна изменяемости заказ выходит
оплаченным или отменённым, а когда именно это случилось, сказано смещением в
секундах от рождения заказа. Момента, которого у исхода нет, нет и в данных:
его место занимает −1, а не ноль, — иначе «оплатили в секунду рождения» было
бы не отличить от «не оплатили вовсе».
**Судьба заказа — исход, его моменты и задержка выгрузки**. Из окна
изменяемости заказ выходит оплаченным или отменённым, а когда именно это
случилось, сказано смещением в секундах от рождения заказа. Момента, которого
у исхода нет, нет и в данных: его место занимает −1, а не ноль, — иначе
«оплатили в секунду рождения» было бы не отличить от «не оплатили вовсе».
Задержка говорит, сколько ранних слепков заказ пропустит; назначенный планом
приезжает сразу.
**Состояние на границе суток** — чтение готовой судьбы, а не накопление:
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
@@ -62,6 +64,7 @@ _DELIVERY_CUMULATIVE = np.cumsum(
)
_OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS)
_MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS)
_SNAPSHOT_DELAY_CUMULATIVE = np.cumsum(world.ORDER_SNAPSHOT_DELAY_WEIGHTS)
class OrderOutcome(IntEnum):
@@ -103,12 +106,18 @@ class Orders:
outcome: NDArray[np.int64]
paid_after: NDArray[np.int64]
cancelled_after: NDArray[np.int64]
snapshot_delay: NDArray[np.int64]
def __len__(self) -> int:
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`: его покупки, к которым бэкенд добавил свои деньги."""
rng = day_stream(seed, day, Component.ORDERS)
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)
created_at = _created_at(rng, purchases)
discount = _discount(purchases, items_total)
snapshot_delay = _snapshot_delay(rng, assigned_order)
return Orders(
day=day,
order_id=purchases.order_id,
@@ -130,6 +140,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
outcome=outcome,
paid_after=paid_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
def arrived(rows: Orders, day: int) -> NDArray[np.bool_]:
"""Какие заказы уже попали в выгрузку к слепку дня `day`."""
return rows.day + rows.snapshot_delay <= day
def _boundary(day: int) -> np.datetime64:
"""Граница суток `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]:
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём.
+3 -5
View File
@@ -7,7 +7,7 @@
│ ├── ось → номер дня: когорта этого дня
│ └── предыстория → глубина: когорта дня до D0
└── дни
└── номер дня → трафик, торговля, заказная сторона, опоздания
└── номер дня → трафик, торговля, заказы, маячки счетчика
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
@@ -40,10 +40,8 @@ class Component(IntEnum):
# каждого заказа читает из его собственного дня
# (docs/architecture/orders/fate.md).
ORDERS = 2
# Событийную сторону — потерю и дубль события — заведёт этап 6; до тех
# пор позиция занята мёртвым именем прежней нарезки, и из него никто не
# бросает.
LATECOMERS = 3
# Маячки счетчика: потеря и повторная отправка событий.
BEACONS = 3
class _Branch(IntEnum):
@@ -45,7 +45,7 @@ from numpy.typing import NDArray
from clickstream_generator import catalog, schema, world
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("
@@ -72,9 +72,10 @@ def events(day: Day, limit: int | None = None) -> list[bytes]:
def orders(window: Sequence[Orders], day: int) -> list[bytes]:
"""Канонические байты слепка дня `day`: по документу JSON на заказ.
`window` — заказы дней окна, от раннего дня к позднему: слепок несёт их
подряд, и порядок строк выходит порядком рождения заказов, он же
возрастание `order_id`. Какие это дни, решает `orders.window`.
`window` — заказы дней окна, от раннего дня к позднему. Уже приехавшие
заказы слепок несёт подряд, поэтому порядок строк остаётся порядком
рождения, он же возрастание `order_id`. Какие это дни, решает
`orders.window`.
Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они
станут `Decimal`, и дробь двоичного числа была бы потерей точности до
@@ -97,7 +98,8 @@ def orders(window: Sequence[Orders], day: int) -> list[bytes]:
delivery = rows.delivery.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(
orjson.dumps(
{
+26 -9
View File
@@ -169,6 +169,16 @@ BUYER_CHECKOUT_OF_CART_PERCENT = 60
# страницу, на которой посетитель его нажал.
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`):
# магнит, обычный, залёживается.
@@ -238,8 +248,8 @@ GOAL_PURCHASE_ID = 42150002
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет
# ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду
# считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок
# заказной стороны. Числа черновые: калибровка — пересборка эталонного
# мира, этап 7.
# заказной стороны. Замер (28 дней, канонический мир, 2026-08-30): 35,5% /
# 49,2% / 15,4% — совпадает с весами.
DELIVERY_KOPECKS_WEIGHTS = (
(0, 35),
(29_900, 50),
@@ -248,12 +258,13 @@ DELIVERY_KOPECKS_WEIGHTS = (
# Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке
# `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ
# выходит либо оплаченным, либо отменённым. Доли черновые: калибровка —
# пересборка эталонного мира, этап 7.
# выходит либо оплаченным, либо отменённым. Замер (28 дней, 2026-08-30):
# отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12% отменен
# неоплаченным; ориентир мастер-спеки ~5%.
ORDER_OUTCOME_WEIGHTS = (
95, # оплачен
3, # оплачен, потом отменён
2, # отменён неоплаченным
2, # отменен неоплаченным
)
# Когда внутри окна случается момент заказа — оплата или отмена: веса часов
@@ -261,8 +272,8 @@ ORDER_OUTCOME_WEIGHTS = (
# с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа,
# рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в
# первые сутки, дальше затухание до края:
# профиль гаснет, поэтому обрыв окна в данных не виден. Веса черновые, как и
# доли исходов.
# профиль гаснет, поэтому обрыв окна в данных не виден. Веса зафиксированы
# вместе с долями исходов — тем же замером выше.
ORDER_MOMENT_HOUR_WEIGHTS = tuple(
weight
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%. Число
# черновое: калибровка — пересборка эталонного мира, этап 7.
# выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Замер
# (28 дней, 2026-08-30): наблюдаемая доля 1,32% — в пределах ориентира.
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
WEEK = 7
# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком —
# Окно замера — две недели: доли по уровням спроса меряются на нем целиком —
# на одном дне у самого редкого уровня набирается слишком мало карточек.
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):
"""Клиентская сторона честная: подтверждение без покупки — это потеря.
Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их
быть не должно ни одной. Обратное тоже верно: покупка без подтверждения
в потоке — заказ ниоткуда.
"""
def test_only_a_lost_purchase_leaves_a_confirmation_without_purchase(
weekday: day.Day,
):
"""Потеря выбрасывает purchase, но оставляет просмотр confirmation."""
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(
@@ -159,7 +159,10 @@ def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
events = day.stream(CANONICAL_SEED, WEEKDAY)
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
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):
@@ -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):
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
orders = rows_of(weekday, commerce.PURCHASE)
numbers = [cell[0] for cell in orders["purchaseID"]]
events = rows_of(weekday, commerce.PURCHASE)
numbers = [cell[0] for cell in events["purchaseID"]]
date = str(weekday.columns["EventDate"][0]).replace("-", "")
assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)]
assert len(set(numbers)) == len(numbers)
# Нумерация идёт в порядке событий: строки дня уже упорядочены по времени.
assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"])
expected = [f"{date}-{place:04d}" for place in range(1, len(weekday.orders) + 1)]
# Порча идет после нумерации: потеря оставляет дыру, а дубль
# повторяет готовый номер и не двигает соседей.
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(
@@ -599,7 +604,23 @@ def test_the_trade_flow_draws_from_its_own_named_branch(
monkeypatch.setattr(commerce, "day_stream", spy)
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]):
@@ -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
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]:
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
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()
for number, events in enumerate(week):
audience = plan.audience(CANONICAL_SEED, number)
flagged |= set(audience.client_id[audience.buyer].tolist())
for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist():
purchases[cookie] = purchases.get(cookie, 0) + 1
rows = rows_of(events, commerce.PURCHASE)
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:
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)
buyers = set(purchases)
+3
View File
@@ -68,6 +68,7 @@ def orders(events: day.Day) -> list[list[Any]]:
int(theirs.outcome[number]),
int(theirs.paid_after[number]),
int(theirs.cancelled_after[number]),
int(theirs.snapshot_delay[number]),
]
for number in range(len(theirs))
]
@@ -88,6 +89,7 @@ def snapshot(events: day.Day) -> dict[str, Any]:
"page": events.page.tolist(),
"product": events.product.tolist(),
"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,
product=weekday.product,
orders=weekday.orders,
purchase_outcome=weekday.purchase_outcome,
)
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
# День любой: хеш дня не зависит ни от соседей, ни от края описи.
REPLAY_DAY = 4
@pytest.fixture(scope="module")
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]
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"])
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)
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)
numbers = [cell[0] for cell in events["purchaseID"]]
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]]):
@@ -88,16 +123,18 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
"""
goods = catalog.catalog()
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
deltas = 0
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(
zip(
events["productID"][number].tolist(),
events["productQuantity"][number].tolist(),
events["productID"][row].tolist(),
events["productQuantity"][row].tolist(),
strict=True,
)
)
@@ -110,7 +147,7 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
)
# Выручка клиента и `items_total` бэкенда — одно число: в событии
# оно дробное, у заказа целое в копейках.
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS)
revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
if mine == client:
assert money.items_total[number] == revenue
continue
@@ -136,15 +173,18 @@ def test_the_discount_comes_from_the_coupon_of_the_event(
for today in start_world:
money = today.orders
events = purchases_of(today)
codes = [cell[0] for cell in events["purchaseCoupon"]]
for number, code in enumerate(codes):
events, row_by_order = purchase_row_by_order(today)
for number, order_id in enumerate(money.order_id):
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]
expected = items_total * percent[code] // 100 if code else 0
assert money.discount[number] == expected, money.order_id[number]
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)
# Иначе проверка не отличила бы новую базу скидки от прежней.
@@ -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)
)
events = purchases_of(weekday)
for number, cookie in enumerate(events["ClientID"].tolist()):
assert weekday.orders.user_id[number] == person[cookie]
by_order = user_id_by_order(weekday)
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 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.seeds import CANONICAL_SEED
# Горизонт эталонного снимка — две недели (спека, раздел 5).
SNAPSHOT_DAYS = 14
# Окно замера плана — две недели: два цикла недельной сезонности.
WINDOW_DAYS = 14
@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():
first = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
first = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
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():
@@ -216,12 +216,12 @@ def test_the_passport_points_into_the_directories():
def test_the_daily_audience_matches_the_spec_band():
"""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)
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)
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:
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]
weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5]
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():
"""Накопленная аудитория растёт с горизонтом и с дневной не сходится."""
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 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():
"""Продление истории днём N+1 не трогает дни 1…N."""
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.new_cookies[:7] == week.new_cookies
def test_counters_count_the_same_audience_that_the_day_gets():
"""Счётчики и дня-функция спрашивают один план — расходиться им негде."""
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
for day in (0, 7, SNAPSHOT_DAYS - 1):
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
for day in (0, 7, WINDOW_DAYS - 1):
actual = plan.audience(CANONICAL_SEED, day).client_id.size
assert actual == counters.audience[day]
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
+1 -1
View File
@@ -78,7 +78,7 @@ def test_day_components_do_not_share_randomness():
Component.TRAFFIC,
Component.COMMERCE,
Component.ORDERS,
Component.LATECOMERS,
Component.BEACONS,
)
assert len({int(component) for component in components}) == 4
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):
# Потерянного purchase в трекере нет, но заказ остается.
if record["order_id"] in bought:
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)
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]):
"""Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`.
@@ -325,7 +384,10 @@ def _purchase_times(today: day_module.Day) -> dict[str, str]:
times = np.datetime_as_string(
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:
+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)
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:
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
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
ENV GF_PATHS_PLUGINS=/usr/share/grafana/plugins \
GF_PLUGINS_PREINSTALL_DISABLED=true
ARG CLICKHOUSE_PLUGIN_VERSION=4.20.0
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
@@ -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
type: Database
timestamp: '2026-07-30T00:00:00+00:00'
+51 -23
View File
@@ -237,35 +237,42 @@ check_airflow_probes() {
}
check_superset() {
local clickhouse_password
local access_token
local available_schemas
local clickhouse_databases
local config
local connection_count
local connection_details
local connection_id
local database_list
local login
local metadata_tables
local password
local port
local metadata_engine
local stored_config
local stored_uri
local schemas
local stored_safe_uri
local stored_uuid
local superset_password
local superset_user
local superset_url
local user
config="$(compose config --format json 2>/dev/null || true)"
user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // 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_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")"
port="$(published_port superset 8088)"
superset_url="http://127.0.0.1:${port}"
login="$(curl -sf --max-time 10 -X POST \
-H 'Content-Type: application/json' \
-d "$(jq -cn --arg username "$user" --arg password "$password" \
'{username: $username, password: $password, provider: "db", refresh: true}')" \
"http://127.0.0.1:${port}/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 && \
jq -e '.access_token | length > 0' >/dev/null 2>&1 <<<"$login"; then
"${superset_url}/api/v1/security/login" 2>/dev/null || true)"
access_token="$(jq -r '.access_token // empty' <<<"$login")"
if curl -sf --max-time 10 "${superset_url}/health" >/dev/null 2>&1 && \
[[ -n "$access_token" ]]; then
pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора'
else
fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора'
@@ -276,28 +283,49 @@ check_superset() {
psql -U "$superset_user" -d superset -tAc \
"SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \
2>/dev/null || true)"
stored_config="$(timeout 30s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
exec -T superset python -c \
"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)" \
2>/dev/null)"
metadata_engine="$(sed -n 's/^SMOKE_METADATA=//p' <<<"$stored_config" | tail -n 1)"
stored_uuid="$(sed -n 's/^SMOKE_UUID=//p' <<<"$stored_config" | tail -n 1)"
stored_uri="$(sed -n 's/^SMOKE_URI=//p' <<<"$stored_config" | tail -n 1)"
# Superset уже собрал приложение. Его REST API читает ту же сохранённую
# модель Database без второго тяжёлого запуска Python.
database_list="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${access_token}" \
"${superset_url}/api/v1/database/" 2>/dev/null || true)"
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' ]] && \
[[ "$metadata_engine" == "postgresql|${superset_user}|postgres-metadata|5432|superset" ]] && \
[[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \
[[ "$stored_uri" == "clickhousedb://bi:${clickhouse_password}@clickhouse-02:8123/default" ]]; then
pass 'метаданные Superset живут в Postgres, подготовленное подключение указывает на clickhouse-02'
[[ "$stored_safe_uri" == 'clickhousedb://bi@clickhouse-02:8123/default' ]]; then
pass 'Postgres для метаданных Superset подготовлен, подключение указывает на clickhouse-02'
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
fi
if printf 'n\n' | timeout 60s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
exec -T superset superset test-db "$stored_uri" >/dev/null 2>&1; then
pass 'Superset успешно проверил извлечённое из метаданных подключение ClickHouse'
schemas="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${access_token}" \
"${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
fail 'Superset не смог проверить извлечённое из метаданных подключение ClickHouse'
fail "сохранённое подключение Superset не видит схему витрин dm: схемы=${available_schemas:-нет}"
fi
}
+8 -2
View File
@@ -2,13 +2,19 @@
--
-- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего
-- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону;
-- Python-задача сопоставляет ее с тремя счетчиками закрытого дня из описи.
-- Python-задача сопоставляет ее с пятью счетчиками закрытого дня из описи.
SELECT
order_day AS data_date,
mismatch_class,
count() AS orders
FROM dm.purchase_vs_orders_v
WHERE mismatch_class IN ('match', 'cancelled', 'amount_delta')
WHERE mismatch_class IN (
'match',
'cancelled',
'lost_event',
'duplicate_event',
'amount_delta'
)
GROUP BY
data_date,
mismatch_class
+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
+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
+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