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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #165

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+4
View File
@@ -157,6 +157,10 @@ Airflow) и названия из кода. Если для понятия ес
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
истории, а текущее описание живого формата — и собирается из кода, а не истории, а текущее описание живого формата — и собирается из кода, а не
пишется руками. пишется руками.
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх - Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
в имени не нужна. в имени не нужна.
+2 -2
View File
@@ -118,8 +118,8 @@ ClickHouse. Форма файла решена, длина — нет: стро
**Стартовый мир**: **Стартовый мир**:
Первые восемь дней оси (понедельник по понедельник), которыми стенд Первые восемь дней оси (понедельник по понедельник), которыми стенд
наполняется при каждом `make up`. Маленький кусок эталонного мира, всегда наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
один и тот же: на нём принимаются следующие этапы. на нем принимаются следующие этапы.
_Избегать_: зерновой мир _Избегать_: зерновой мир
**Опись мира**: **Опись мира**:
+5
View File
@@ -262,6 +262,11 @@ uv run --project generator python -m clickstream_generator batch \
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники `0` — очистка закончена, а начальная генерация ещё не завершена. Работники
начинают играть только с позиции `8` и больше. начинают играть только с позиции `8` и больше.
В форме запуска можно выбрать только число дней. Первый день работник берет из
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
позицию работник пишет в журнал шага `remember_played`.
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно — Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
целиком — [ADR 0009](docs/adr/0009-world-control.md). целиком — [ADR 0009](docs/adr/0009-world-control.md).
+10 -5
View File
@@ -48,10 +48,10 @@ x-kafka-target: &kafka-target
x-world-starting-days: &world-starting-days "8" x-world-starting-days: &world-starting-days "8"
# Общие образы Airflow и Superset собирают службы инициализации. Причина
# отдельного build: ADR 0001.
x-airflow-common: &airflow-common x-airflow-common: &airflow-common
image: clickstream-airflow:local image: clickstream-airflow:local
build:
context: ./infra/airflow
environment: environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__PARALLELISM: "4" AIRFLOW__CORE__PARALLELISM: "4"
@@ -105,8 +105,6 @@ x-generator-common: &generator-common
x-superset-common: &superset-common x-superset-common: &superset-common
image: clickstream-superset:local image: clickstream-superset:local
build:
context: ./infra/superset
environment: environment:
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env} SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
@@ -257,6 +255,8 @@ services:
airflow-init: airflow-init:
<<: *airflow-common <<: *airflow-common
build:
context: ./infra/airflow
user: "0:0" user: "0:0"
restart: "no" restart: "no"
depends_on: depends_on:
@@ -310,7 +310,10 @@ services:
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro - ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
- airflow_logs:/opt/airflow/logs - airflow_logs:/opt/airflow/logs
- airflow_auth:/opt/airflow/auth - airflow_auth:/opt/airflow/auth
mem_limit: 640m # LocalExecutor запускает здесь до четырех процессов задач одновременно.
# 640 МиБ дали OOM, а 1 ГиБ - давление на memory.max при штатном dq_check;
# 1,5 ГиБ оставляет запас при AIRFLOW__CORE__PARALLELISM=4.
mem_limit: 1536m
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"] test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"]
interval: 10s interval: 10s
@@ -337,6 +340,8 @@ services:
superset-init: superset-init:
<<: *superset-common <<: *superset-common
build:
context: ./infra/superset
restart: "no" restart: "no"
healthcheck: healthcheck:
disable: true disable: true
+434
View File
@@ -0,0 +1,434 @@
"""Трансформации слоя DDS: сборка моделей.
Заказ и сессия собираются заменой дневных партиций. Карта идентичностей
пересчитывается целиком после заказа, потому что читает его текущую модель.
Расписания у дага нет и параметров тоже: объём каждого прогона он выводит сам
из состояния слоёв и позиции мира, поэтому один и тот же запуск одинаково
верен из триггера дневного конвейера, с кнопки менти и после падения
(docs/architecture/etl.md, «Объём прогона»).
Заказ и сессия гасят повтор заменой партиции целиком: день собирается в
таблицу-двойник и подменяет собой партицию цели. Карта пишет полный набор пар
новой версией, а точное чтение даёт FINAL. Замена партиции разобрана в
sql/dds/order_replace.sql; точное чтение карты описано в
sql/ddl/50-dds-views.sql.
"""
from __future__ import annotations
import datetime
import logging
import os
import time
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.sdk import Variable, dag, task, task_group
# Пропуск приезжает из Task SDK: в airflow.exceptions он объявлен устаревшим
# и говорит об этом в журнал задачи (проверено на 3.3.0).
from airflow.sdk.exceptions import AirflowSkipException
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
SQL_ROOT = Path("/opt/airflow/sql")
CLICKHOUSE_CONNECTION = "clickhouse_default"
CLICKHOUSE_CLUSTER = "clickstream_cluster"
KAFKA_BOOTSTRAP_SERVERS = os.environ["KAFKA_BOOTSTRAP_SERVERS"]
HITS_TOPIC = os.environ["KAFKA_TOPIC"]
HITS_GROUP = "clickstream_hits"
HITS_PARTITIONS = (0, 1)
READINESS_TIMEOUT_SECONDS = 300
READINESS_POLL_SECONDS = 3
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит
# пульт мира, и только она отличает прожитый день от живого — зачем это
# сессиям, разобрано в sql/dds/session_scope.sql.
WORLD_POSITION = "world_position"
def _kafka_readiness() -> tuple[bool, str]:
"""Проверить, дочитала ли группа оба раздела событий."""
from confluent_kafka import ConsumerGroupTopicPartitions, TopicPartition
from confluent_kafka.admin import AdminClient, OffsetSpec
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
groups = admin.list_consumer_groups(request_timeout=10).result()
if groups.errors:
raise RuntimeError(f"Kafka не вернула список групп: {groups.errors}")
group_exists = any(group.group_id == HITS_GROUP for group in groups.valid)
committed: dict[int, int] = {}
if group_exists:
request = ConsumerGroupTopicPartitions(
HITS_GROUP,
[TopicPartition(HITS_TOPIC, partition) for partition in HITS_PARTITIONS],
)
result = admin.list_consumer_group_offsets([request], request_timeout=10)[
HITS_GROUP
].result()
for topic_partition in result.topic_partitions:
if topic_partition.error is None and topic_partition.offset >= 0:
committed[topic_partition.partition] = topic_partition.offset
high_watermarks: dict[int, int] = {}
watermark_errors: dict[int, str] = {}
# OffsetSpec.latest возвращает следующее смещение после хвоста; сверено
# через Context7 и на установленном confluent-kafka 2.15.0.
latest = admin.list_offsets(
{
TopicPartition(HITS_TOPIC, partition): OffsetSpec.latest()
for partition in HITS_PARTITIONS
},
request_timeout=10,
)
for topic_partition, future in latest.items():
try:
offset = future.result().offset
if offset >= 0:
high_watermarks[topic_partition.partition] = offset
except Exception as error:
watermark_errors[topic_partition.partition] = str(error)
all_known = group_exists
total_lag = 0
parts: list[str] = []
for partition in HITS_PARTITIONS:
offset = committed.get(partition)
high = high_watermarks.get(partition)
lag = high - offset if offset is not None and high is not None else None
if lag is None or lag < 0:
all_known = False
else:
total_lag += lag
logging.info(
"Kafka %s[%s]: подтверждено=%s, верхнее смещение=%s, отставание=%s",
HITS_TOPIC,
partition,
offset,
high,
lag,
)
parts.append(
f"{HITS_TOPIC}[{partition}]: подтверждено={offset}, "
f"верхнее смещение={high}, отставание={lag}"
)
if partition in watermark_errors:
parts[-1] += (
f", ошибка чтения верхнего смещения={watermark_errors[partition]}"
)
if not group_exists:
logging.info("группа потребителей %s отсутствует", HITS_GROUP)
lag_text = str(total_lag) if all_known else "неизвестно"
logging.info("суммарное отставание Kafka: %s", lag_text)
detail = "; ".join(parts)
if not group_exists:
detail = f"группа {HITS_GROUP} отсутствует; {detail}"
return all_known and total_lag == 0, detail
def _delivery_queue_readiness() -> tuple[bool, str]:
"""Проверить, пуста ли очередь доставки событий на каждой ноде."""
query = f"""
SELECT
node,
sum(queued_files) AS files,
arrayStringConcat(
groupArrayIf(
last_exception,
queued_files > 0 AND notEmpty(last_exception)
),
'; '
) AS last_exception
FROM
(
-- У пустой очереди нет строк; system.one оставляет обе ноды
-- видимыми и в зеленом опросе.
SELECT
hostName() AS node,
toUInt64(0) AS queued_files,
'' AS last_exception
FROM clusterAllReplicas('{CLICKHOUSE_CLUSTER}', system.one)
UNION ALL
SELECT
hostName(),
data_files + broken_data_files AS queued_files,
last_exception
FROM clusterAllReplicas(
'{CLICKHOUSE_CLUSTER}', system.distribution_queue
)
WHERE database = 'ods' AND table = 'event_dist'
)
GROUP BY node
ORDER BY node
"""
rows = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION).get_records(query)
ready = True
nodes: list[str] = []
for node, files, last_exception in rows:
files = int(files)
ready = ready and files == 0
logging.info(
"очередь ods.event_dist на %s: файлов=%s, последняя ошибка=%s",
node,
files,
last_exception or "нет",
)
detail = f"{node}: файлов={files}"
if last_exception:
detail += f", последняя ошибка={last_exception}"
nodes.append(detail)
return ready, "; ".join(nodes)
def _wait_for_phase(
probe,
phase: str,
deadline: float,
) -> None:
"""Дождаться прохождения одного барьера до общего крайнего срока."""
last_observation = "первый опрос не выполнен"
last_error: str | None = None
while time.monotonic() < deadline:
try:
ready, last_observation = probe()
last_error = None
except Exception as error:
ready = False
last_error = str(error)
logging.warning("%s: ошибка опроса: %s", phase, last_error)
if ready:
return
remaining = deadline - time.monotonic()
if remaining > 0:
time.sleep(min(READINESS_POLL_SECONDS, remaining))
detail = last_observation
if last_error:
detail += f"; ошибка последнего опроса: {last_error}"
raise AirflowException(
f"общий срок ожидания готовности в {READINESS_TIMEOUT_SECONDS} с "
f'истек на этапе "{phase}": {detail}'
)
def _world_position() -> int:
"""Позиция мира числом, с внятным отказом на непригодном значении.
Ту же переменную читает пульт, но общего модуля у дагов нет намеренно:
связь между пультом и трансформацией держит сама переменная, а не импорт.
"""
raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None:
raise AirflowException(
"мир ещё не создан: выполните make up и дождитесь world_initialize"
)
try:
position = int(raw_position)
except (TypeError, ValueError) as error:
raise AirflowException(
f"world_position={raw_position!r} не является номером дня; "
"выполните make rebuild-storage"
) from error
if position < 1:
raise AirflowException(
f"мир не готов: world_position={position}; "
"выполните make up или make rebuild-storage"
)
return position
def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
"""Дни отрезка строками — в таком виде их ждёт замена партиции."""
return [
(first_day + datetime.timedelta(days=shift)).isoformat()
for shift in range((last_day - first_day).days + 1)
]
@task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Разложить дни в аргументы размноженной задачи замены.
Шаг существует ради ограничения Airflow: размножать задачу можно
только по целому возврату другой задачи — ни по ключу словаря, ни по
карте поверх него. Отсюда отдельный возврат под один список.
День едет в запрос подстановкой Jinja, а не параметром ClickHouse:
в ALTER … PARTITION сервер параметр не подставляет — замерено,
отвечает синтаксической ошибкой. Значение при этом не внешнее, его
вернул сам ClickHouse запросом объёма.
"""
return [{"params": {"day": day}} for day in scope["days"]]
@dag(
dag_id="dds_transform",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
# Два прогона разом собирали бы один и тот же двойник: второй затёр бы
# чужую сборку между вставкой и заменой партиций.
max_active_runs=1,
template_searchpath=str(SQL_ROOT),
tags=["dds"],
)
def dds_transform():
"""Пересобрать сущности DDS по текущему состоянию источников."""
@task
def wait_for_source() -> None:
"""Дождаться чтения событий из Kafka и их доставки в ODS."""
deadline = time.monotonic() + READINESS_TIMEOUT_SECONDS
_wait_for_phase(_kafka_readiness, "Kafka", deadline)
_wait_for_phase(
_delivery_queue_readiness,
"очередь доставки ClickHouse",
deadline,
)
@task_group(group_id="order")
def order():
"""Заказ: дышащее окно плюс дни, которых в слое ещё нет."""
@task
def scope() -> dict[str, object]:
"""Спросить у хранилища, какие дни собирает этот запуск."""
query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8")
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query)
# Пустой ответ означает пустой источник: собирать нечего, и
# зеленеть на этом нельзя — прогон встаёт с внятной причиной.
if row is None:
raise AirflowException(
"в ods.order нет ни одного заказа: собирать нечего; "
"проверьте, дошёл ли слепок до хранилища"
)
first_day, last_day = row
days = _days(first_day, last_day)
logging.info(
"объём пересборки: %s … %s, дней %d",
first_day,
last_day,
len(days),
)
return {
"first_day": first_day.isoformat(),
"last_day": last_day.isoformat(),
"days": days,
}
days = scope()
rebuild = SQLExecuteQueryOperator(
task_id="rebuild",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/order_rebuild.sql",
parameters={
"load_id": "{{ run_id }}",
"first_day": days["first_day"],
"last_day": days["last_day"],
},
split_statements=True,
do_xcom_push=False,
)
# Замена идёт по партиции за раз — другой формы у операции нет. День
# становится отдельной задачей: в интерфейсе видно, сколько дней несёт
# прогон, а повтор после отказа стоит одного дня, а не всей пересборки.
replace = SQLExecuteQueryOperator.partial(
task_id="replace",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/order_replace.sql",
do_xcom_push=False,
).expand_kwargs(replacements(days))
rebuild >> replace
@task_group(group_id="session")
def session():
"""Сессия: дни, которые мир уже прожил, а слой ещё не собрал."""
@task
def scope() -> dict[str, object]:
"""Спросить у хранилища и у позиции мира, какие дни собирает запуск."""
query = (SQL_ROOT / "dds" / "session_scope.sql").read_text(encoding="utf-8")
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query, parameters={"position": _world_position()})
if row is None:
raise AirflowException(
"в ods.event нет ни одного события: собирать нечего; "
"проверьте, дошёл ли поток до хранилища"
)
first_day, last_day = row
# Пропуск, а не отказ: слой полон по последний прожитый день, а
# сегодняшний ещё идёт. Это обычное состояние повторного прогона,
# и в интерфейсе оно видно пропущенной группой.
if first_day is None:
raise AirflowSkipException(
f"сессии собраны по {last_day} включительно, "
"новых прожитых дней нет"
)
days = _days(first_day, last_day)
logging.info(
"объём сборки: %s … %s, дней %d",
first_day,
last_day,
len(days),
)
return {
"first_day": first_day.isoformat(),
"last_day": last_day.isoformat(),
"days": days,
}
days = scope()
rebuild = SQLExecuteQueryOperator(
task_id="rebuild",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/session_rebuild.sql",
parameters={
"load_id": "{{ run_id }}",
"first_day": days["first_day"],
"last_day": days["last_day"],
},
split_statements=True,
do_xcom_push=False,
)
replace = SQLExecuteQueryOperator.partial(
task_id="replace",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/session_replace.sql",
do_xcom_push=False,
).expand_kwargs(replacements(days))
rebuild >> replace
source_ready = wait_for_source()
orders = order()
sessions = session()
source_ready >> [orders, sessions]
identity_map = SQLExecuteQueryOperator(
task_id="identity_map",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/identity_map_rebuild.sql",
parameters={"load_id": "{{ run_id }}"},
do_xcom_push=False,
)
orders >> identity_map
dds_transform()
+208
View File
@@ -0,0 +1,208 @@
"""Трансформации слоя DM: сборка стендовых витрин.
Выручка наследует ритм заказов. Сверка соединяет окно заказов с прожитым
хвостом событий. Обе заменяют дневные партиции. Трафик наследует
ретроспективность карты идентичностей и пересчитывает всю историю новой
версией. Объем каждого прогона выводится из состояния слоев.
"""
from __future__ import annotations
import datetime
import logging
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.sdk import Variable, dag, task, task_group
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
SQL_ROOT = Path("/opt/airflow/sql")
CLICKHOUSE_CONNECTION = "clickhouse_default"
# Та же ось модельного времени, что у DDS. Общего модуля у дагов нет
# намеренно: связь держит переменная Airflow, а не импорт одного дага другим.
WORLD_POSITION = "world_position"
def _world_position() -> int:
"""Позиция мира для границы прожитых событий сверки.
Причина отдельного чтения переменной разобрана у одноименной функции в
``dds_transform.py``.
"""
raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None:
raise AirflowException(
"мир еще не создан: выполните make up и дождитесь world_initialize"
)
try:
position = int(raw_position)
except (TypeError, ValueError) as error:
raise AirflowException(
f"world_position={raw_position!r} не является номером дня; "
"выполните make rebuild-storage"
) from error
if position < 1:
raise AirflowException(
f"мир не готов: world_position={position}; "
"выполните make up или make rebuild-storage"
)
return position
def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
"""Дни отрезка строками - в таком виде их ждет замена партиции."""
return [
(first_day + datetime.timedelta(days=shift)).isoformat()
for shift in range((last_day - first_day).days + 1)
]
@task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Разложить дни в аргументы размноженной задачи замены.
Отдельный словарь нужен каждому экземпляру ``expand_kwargs``; тот же
прием разобран в ``dds_transform.py`` на замене партиций заказов.
"""
return [{"params": {"day": day}} for day in scope["days"]]
@dag(
dag_id="dm_transform",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
template_searchpath=str(SQL_ROOT),
tags=["dm"],
)
def dm_transform():
"""Пересобрать стендовые витрины по текущему состоянию DDS."""
@task_group(group_id="revenue_daily")
def revenue_daily():
"""Выручка: изменяемое окно заказов и пропущенная история."""
@task
def scope() -> dict[str, object]:
"""Спросить у хранилища, какие дни пересобирает запуск."""
query = (SQL_ROOT / "dm" / "revenue_daily_scope.sql").read_text(
encoding="utf-8"
)
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query)
if row is None:
raise AirflowException(
"нет слепка заказов в ODS или готовых заказов в DDS; "
"дождитесь загрузки слепка и сначала выполните dds_transform"
)
first_day, last_day = row
days = _days(first_day, last_day)
logging.info(
"объем пересборки: %s … %s, дней %d",
first_day,
last_day,
len(days),
)
return {
"first_day": first_day.isoformat(),
"last_day": last_day.isoformat(),
"days": days,
}
days = scope()
rebuild = SQLExecuteQueryOperator(
task_id="rebuild",
conn_id=CLICKHOUSE_CONNECTION,
sql="dm/revenue_daily_rebuild.sql",
parameters={
"load_id": "{{ run_id }}",
"first_day": days["first_day"],
"last_day": days["last_day"],
},
split_statements=True,
do_xcom_push=False,
)
replace = SQLExecuteQueryOperator.partial(
task_id="replace",
conn_id=CLICKHOUSE_CONNECTION,
sql="dm/revenue_daily_replace.sql",
do_xcom_push=False,
).expand_kwargs(replacements(days))
rebuild >> replace
revenue_daily()
@task_group(group_id="purchase_vs_orders")
def purchase_vs_orders():
"""Сверка: окно заказов, прожитый хвост и предварительные дни."""
@task
def scope() -> dict[str, object]:
"""Спросить у обоих источников, какие дни пересобрать."""
query = (SQL_ROOT / "dm" / "purchase_vs_orders_scope.sql").read_text(
encoding="utf-8"
)
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query, parameters={"position": _world_position()})
if row is None:
raise AirflowException(
"нет принятого слепка заказов или прожитых покупок; "
"дождитесь источников и сначала выполните dds_transform"
)
first_day, last_day = row
days = _days(first_day, last_day)
logging.info(
"объем пересборки: %s … %s, дней %d",
first_day,
last_day,
len(days),
)
return {
"first_day": first_day.isoformat(),
"last_day": last_day.isoformat(),
"days": days,
}
days = scope()
rebuild = SQLExecuteQueryOperator(
task_id="rebuild",
conn_id=CLICKHOUSE_CONNECTION,
sql="dm/purchase_vs_orders_rebuild.sql",
parameters={
"load_id": "{{ run_id }}",
"first_day": days["first_day"],
"last_day": days["last_day"],
},
split_statements=True,
do_xcom_push=False,
)
replace = SQLExecuteQueryOperator.partial(
task_id="replace",
conn_id=CLICKHOUSE_CONNECTION,
sql="dm/purchase_vs_orders_replace.sql",
do_xcom_push=False,
).expand_kwargs(replacements(days))
rebuild >> replace
purchase_vs_orders()
SQLExecuteQueryOperator(
task_id="daily_traffic",
conn_id=CLICKHOUSE_CONNECTION,
sql="dm/daily_traffic_rebuild.sql",
parameters={"load_id": "{{ run_id }}"},
do_xcom_push=False,
)
dm_transform()
+319
View File
@@ -0,0 +1,319 @@
"""Проверки качества стендовых моделей и витрин.
Пять проверок сначала собирают общий донор. После его полной сборки дневные
партиции публикуются одной серией замен, и только затем именованные задачи
утверждают результат. Расхождение поэтому остаётся в ``dm.dq_summary_v`` до
красного завершения дага; ошибка сборки прежнюю сводку не меняет.
"""
from __future__ import annotations
import datetime
import json
import logging
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.sdk import Variable, dag, get_current_context, task, task_group
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
SQL_ROOT = Path("/opt/airflow/sql")
INVENTORY_PATH = Path("/opt/airflow/world/data/world-inventory.json")
CLICKHOUSE_CONNECTION = "clickhouse_default"
WORLD_POSITION = "world_position"
DETAIL_LIMIT = 20
SUMMARY_COLUMNS = [
"data_date",
"check_name",
"reference_rows",
"actual_rows",
"failed_rows",
"status",
"_load_id",
"_load_ts",
]
def _clickhouse_hook() -> ClickHouseHook:
"""Подключение, которое дожидается записи в Distributed-таблицу."""
return ClickHouseHook(
clickhouse_conn_id=CLICKHOUSE_CONNECTION,
session_settings={"distributed_foreground_insert": 1},
)
def _world_position() -> int:
"""Первый несыгранный день оси мира."""
raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None:
raise AirflowException(
"мир еще не создан: выполните make up и дождитесь world_initialize"
)
try:
position = int(raw_position)
except (TypeError, ValueError) as error:
raise AirflowException(
f"world_position={raw_position!r} не является номером дня; "
"выполните make rebuild-storage"
) from error
if position < 1:
raise AirflowException(
f"мир не готов: world_position={position}; "
"выполните make up или make rebuild-storage"
)
return position
def _publish_summary(
check_name: str,
summaries: list[tuple[object, ...]],
) -> list[str]:
"""Записать дневную сводку и вернуть малый срез готовой диагностики."""
if not summaries:
raise AirflowException(f"{check_name}: область проверки пуста")
diagnostics: list[str] = []
context = get_current_context()
load_id = context["run_id"]
load_ts = datetime.datetime.now(datetime.UTC)
rows = []
for (
data_date,
reference_rows,
actual_rows,
failed_rows,
details,
) in summaries:
rows.append(
(
data_date,
check_name,
reference_rows,
actual_rows,
failed_rows,
"pass" if failed_rows == 0 else "fail",
load_id,
load_ts,
)
)
diagnostics.extend(details[: DETAIL_LIMIT - len(diagnostics)])
_clickhouse_hook().bulk_insert_rows(
"dm.dq_summary_stage_dist",
rows,
column_names=SUMMARY_COLUMNS,
)
return diagnostics
def _build_sql_check(
check_name: str,
sql_name: str,
parameters: dict[str, object] | None = None,
) -> list[str]:
"""Выполнить SQL-сверку и записать ее дневные итоги в донор."""
query = (SQL_ROOT / "dq" / sql_name).read_text(encoding="utf-8")
summaries = _clickhouse_hook().get_records(query, parameters=parameters)
return _publish_summary(check_name, summaries)
@task
def build_sql_check(check_name: str, sql_name: str) -> list[str]:
"""Собрать проверку, которой не нужны координаты модельного мира."""
return _build_sql_check(check_name, sql_name)
@task
def build_sessions_vs_reference() -> list[str]:
"""Сверить сессии только в пределах прожитых дней мира."""
return _build_sql_check(
"sessions_vs_reference",
"sessions_vs_reference.sql",
parameters={"position": _world_position()},
)
@task
def build_classes_vs_inventory() -> list[str]:
"""Сопоставить классы закрытых дней с техническим оракулом генератора."""
inventory = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
expected = {
(datetime.date.fromisoformat(day["date"]), mismatch_class): count
for day in inventory["days"]
if "orders" in day
for mismatch_class, count in day["orders"].items()
}
if not expected:
raise AirflowException("classes_vs_inventory: в описи нет закрытых дней")
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
# GROUP BY закономерно не возвращает группу из нуля строк.
actual = {key: 0 for key, count in expected.items() if count == 0}
actual.update(
{
(data_date, mismatch_class): count
for data_date, mismatch_class, count in _clickhouse_hook().get_records(
query
)
if (data_date, mismatch_class) in expected
}
)
summaries = []
for data_date in sorted({day for day, _ in expected}):
details = []
reference_rows = 0
actual_rows = 0
failed_rows = 0
for (day, mismatch_class), reference_count in sorted(expected.items()):
if day != data_date:
continue
actual_key = (day, mismatch_class)
actual_count = actual.get(actual_key)
reference_rows += 1
actual_rows += int(actual_key in actual)
if reference_count != actual_count:
failed_rows += 1
actual_text = actual_count if actual_count is not None else "нет ключа"
details.append(
f"{day} key={mismatch_class}: "
f"эталон={reference_count}, "
f"объект={actual_text}"
)
summaries.append((data_date, reference_rows, actual_rows, failed_rows, details))
return _publish_summary("classes_vs_inventory", summaries)
@task
def publication_scope() -> list[dict[str, dict[str, str]]]:
"""Вернуть дни полностью собранного донора для размноженной замены."""
rows = _clickhouse_hook().get_records(
"SELECT DISTINCT data_date FROM dm.dq_summary_stage_dist ORDER BY data_date"
)
if not rows:
raise AirflowException("донор dm.dq_summary пуст: публиковать нечего")
return [{"params": {"day": data_date.isoformat()}} for (data_date,) in rows]
@task
def drop_stale_partitions() -> None:
"""Убрать дни, исчезнувшие после починки с обеих сторон сверки."""
hook = _clickhouse_hook()
stale_days = hook.get_records(
"""
SELECT DISTINCT data_date
FROM dm.dq_summary_dist
WHERE data_date GLOBAL NOT IN
(SELECT data_date FROM dm.dq_summary_stage_dist)
ORDER BY data_date
"""
)
for (data_date,) in stale_days:
# ALTER PARTITION не принимает параметр ClickHouse. Значение пришло
# из колонки Date самого хранилища и форматируется без внешнего ввода.
hook.run(
"ALTER TABLE dm.dq_summary_rep ON CLUSTER clickstream_cluster "
f"DROP PARTITION '{data_date.isoformat()}'"
)
@task
def assert_check(check_name: str, diagnostics: list[str]) -> None:
"""Покрасить именованную задачу по уже опубликованной сводке."""
failed_days = _clickhouse_hook().get_records(
"""
SELECT data_date, reference_rows, actual_rows, failed_rows
FROM dm.dq_summary_v
WHERE check_name = {check_name:String} AND failed_rows > 0
ORDER BY data_date
""",
parameters={"check_name": check_name},
)
if not failed_days:
return
for detail in diagnostics:
logging.error("%s: %s", check_name, detail)
summary = "; ".join(
f"{data_date}: эталонных ключей {reference_rows}, "
f"фактических {actual_rows}, нарушено {failed_rows}"
for data_date, reference_rows, actual_rows, failed_rows in failed_days
)
raise AirflowException(f"{check_name}: {summary}")
@dag(
dag_id="dq_check",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
template_searchpath=str(SQL_ROOT),
tags=["dq"],
)
def dq_check():
"""Собрать, опубликовать и утвердить текущее качество данных."""
prepare = SQLExecuteQueryOperator(
task_id="prepare",
conn_id=CLICKHOUSE_CONNECTION,
sql="dq/prepare.sql",
do_xcom_push=False,
)
sessions = build_sessions_vs_reference()
classes = build_classes_vs_inventory.override(
task_id="build_classes_vs_inventory"
)()
revenue = build_sql_check.override(task_id="build_revenue_daily_vs_dds")(
"revenue_daily_vs_dds", "revenue_daily_vs_dds.sql"
)
purchases = build_sql_check.override(task_id="build_purchase_vs_orders_vs_dds")(
"purchase_vs_orders_vs_dds", "purchase_vs_orders_vs_dds.sql"
)
traffic = build_sql_check.override(task_id="build_daily_traffic_vs_dds")(
"daily_traffic_vs_dds", "daily_traffic_vs_dds.sql"
)
builders = [sessions, classes, revenue, purchases, traffic]
prepare >> builders
days = publication_scope()
for builder in builders:
builder >> days
replace = SQLExecuteQueryOperator.partial(
task_id="publish",
conn_id=CLICKHOUSE_CONNECTION,
sql="dq/replace.sql",
do_xcom_push=False,
).expand_kwargs(days)
sessions_assertion = assert_check.override(task_id="sessions_vs_reference")(
"sessions_vs_reference", sessions
)
classes_assertion = assert_check.override(task_id="classes_vs_inventory")(
"classes_vs_inventory", classes
)
@task_group(group_id="marts_vs_dds")
def assert_marts():
return [
assert_check.override(task_id="revenue_daily_vs_dds")(
"revenue_daily_vs_dds", revenue
),
assert_check.override(task_id="purchase_vs_orders_vs_dds")(
"purchase_vs_orders_vs_dds", purchases
),
assert_check.override(task_id="daily_traffic_vs_dds")(
"daily_traffic_vs_dds", traffic
),
]
assertions = [sessions_assertion, classes_assertion, *assert_marts()]
remove_stale = drop_stale_partitions()
replace >> remove_stale >> assertions
dq_check()
+53
View File
@@ -0,0 +1,53 @@
"""Дневной конвейер: DDS, DM и проверка качества.
Своих преобразований у дага нет. Ждущие триггеры связывают состояния:
если слой краснеет, конвейер не запускает следующий слой и краснеет сам.
"""
from __future__ import annotations
import datetime
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.sdk import dag
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
@dag(
dag_id="etl_pipeline",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
tags=["etl"],
)
def etl_pipeline():
"""Собрать слои последовательно по текущему состоянию данных."""
dds = TriggerDagRunOperator(
task_id="trigger_dds",
trigger_dag_id="dds_transform",
wait_for_completion=True,
poke_interval=10,
)
dm = TriggerDagRunOperator(
task_id="trigger_dm",
trigger_dag_id="dm_transform",
wait_for_completion=True,
poke_interval=10,
)
# Airflow 3.3: оператор провайдера standard с wait_for_completion=True
# поднимает AirflowException, если запущенный даг завершился красным.
# Поведение и путь импорта повторно сверены через Context7 27 августа
# 2026 года.
dq = TriggerDagRunOperator(
task_id="trigger_dq",
trigger_dag_id="dq_check",
wait_for_completion=True,
poke_interval=10,
)
dds >> dm >> dq
etl_pipeline()
+138
View File
@@ -0,0 +1,138 @@
"""Ручная самопроверка учебных витрин DM против их источников."""
from __future__ import annotations
import datetime
import logging
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.sdk import dag, task
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
SQL_ROOT = Path("/opt/airflow/sql/dq")
CLICKHOUSE_CONNECTION = "clickhouse_default"
DETAIL_LIMIT = 20
def _clickhouse_hook() -> ClickHouseHook:
"""Подключение к ClickHouse для чтения обеих сторон сверки."""
return ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
def _mart_exists(hook: ClickHouseHook, mart: str) -> bool:
"""Проверить наличие публичной витрины на ноде подключения."""
database, name = mart.split(".", maxsplit=1)
rows = hook.get_records(
"""
SELECT count()
FROM system.tables
WHERE database = {database:String} AND name = {name:String}
""",
parameters={"database": database, "name": name},
)
return rows[0][0] == 1
@task
def check_mart(
mart: str,
lab_path: str,
sql_name: str,
scope_sql_name: str | None = None,
) -> None:
"""Сверить одну домашнюю витрину с независимым ответом из источника."""
hook = _clickhouse_hook()
if not _mart_exists(hook, mart):
raise AirflowException(f"витрина {mart} не найдена; выполните ТЗ {lab_path}")
try:
query = (SQL_ROOT / sql_name).read_text(encoding="utf-8")
scope_query = (
(SQL_ROOT / scope_sql_name).read_text(encoding="utf-8")
if scope_sql_name is not None
else None
)
except OSError as error:
raise AirflowException(
f"стенд не может прочитать запрос проверки {sql_name}: {error}; "
"сообщите наставнику"
) from error
try:
parameter_sets = [None]
if scope_query is not None:
parameter_sets = [{"day": day} for (day,) in hook.get_records(scope_query)]
summaries = [
summary
for parameters in parameter_sets
for summary in hook.get_records(query, parameters=parameters)
]
except Exception as error:
raise AirflowException(
f"не удалось проверить витрину {mart}: {error}; "
f"сверьте договор колонок и проверку в ТЗ {lab_path}"
) from error
failed_rows = sum(summary[3] for summary in summaries)
if failed_rows == 0:
return
diagnostics = [detail for summary in summaries for detail in summary[4]][
:DETAIL_LIMIT
]
for detail in diagnostics:
logging.error("%s: %s", mart, detail)
days = "; ".join(
f"{data_date}: ключей источника {reference_rows}, "
f"ключей витрины {actual_rows}, расходятся {day_failed_rows}"
for data_date, reference_rows, actual_rows, day_failed_rows, _ in summaries
if day_failed_rows > 0
)
compared_sides = diagnostics[0]
raise AirflowException(
f"витрина {mart} расходится с источником: {days}. "
f"Пример сравнения: {compared_sides}. Сверьтесь с ТЗ {lab_path}"
)
@dag(
dag_id="homework_check",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
tags=["dm", "labs"],
)
def homework_check():
"""Дать отдельный результат самопроверки по каждой домашней витрине."""
check_mart.override(task_id="events_enriched")(
"dm.events_enriched_v",
"docs/labs/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()
+36 -4
View File
@@ -12,6 +12,9 @@
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
`orders_ingest`. `orders_ingest`.
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
только разгоняет источники: слои после нее менти догоняет ручным запуском.
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день. при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
@@ -25,6 +28,7 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import logging
import os import os
from airflow.exceptions import AirflowException from airflow.exceptions import AirflowException
@@ -138,7 +142,19 @@ def _ingest_orders() -> TriggerDagRunOperator:
tags=TAGS, tags=TAGS,
params={ params={
"days": Param( "days": Param(
1, type="integer", minimum=1, maximum=7, title="Сколько дней прожить" 1,
type="integer",
minimum=1,
maximum=7,
title="Сколько дней прожить",
# description_md рендерится подсказкой прямо в форме запуска
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
description_md=(
"Форма задает только число дней. Номер первого несыгранного "
"дня работник берет из переменной `world_position` (Admin → "
"Variables). Поля Logical date и Run ID нужны самому Airflow "
"и на выбор дня не влияют."
),
) )
}, },
) )
@@ -146,14 +162,20 @@ def world_next_day():
"""Прожить следующие дни пачкой, без пауз. """Прожить следующие дни пачкой, без пауз.
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
нажимом, а не десятью: сколько дней играть — параметр запуска. нажимом, а не десятью: сколько дней играть — параметр запуска. Первый
день работник берет из `world_position`. Логическая дата прогона на выбор
дня не влияет.
""" """
@task @task
def remember_played(first_day: int) -> None: def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранным дням и только по успеху.""" """Позиция ставится по сыгранным дням и только по успеху."""
days = get_current_context()["params"]["days"] days = get_current_context()["params"]["days"]
Variable.set(WORLD_POSITION, str(first_day + days)) position = first_day + days
Variable.set(WORLD_POSITION, str(position))
logging.info(
"дни %s–%s сыграны; новая позиция — %s", first_day, position - 1, position
)
first_day = first_unplayed_day() first_day = first_unplayed_day()
played = _generator( played = _generator(
@@ -193,12 +215,15 @@ def world_live_day():
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
реальные минуты, и суточная волна разворачивается на глазах. Запускается реальные минуты, и суточная волна разворачивается на глазах. Запускается
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`. руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
Следующий день работник берет из `world_position`. Логическая дата прогона
на выбор дня не влияет.
""" """
@task @task
def remember_played(first_day: int) -> None: def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранному дню и только по успеху.""" """Позиция ставится по сыгранному дню и только по успеху."""
Variable.set(WORLD_POSITION, str(first_day + 1)) Variable.set(WORLD_POSITION, str(first_day + 1))
logging.info("сыгран день %s; новая позиция — %s", first_day, first_day + 1)
first_day = first_unplayed_day() first_day = first_unplayed_day()
played = _generator("play_day", ["live", "--day", PLAYED_DAY]) played = _generator("play_day", ["live", "--day", PLAYED_DAY])
@@ -209,7 +234,14 @@ def world_live_day():
["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC], ["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC],
) )
first_day >> played >> sent >> _ingest_orders() >> remember_played(first_day) position = remember_played(first_day)
etl = TriggerDagRunOperator(
task_id="trigger_etl_pipeline",
trigger_dag_id="etl_pipeline",
wait_for_completion=True,
poke_interval=10,
)
first_day >> played >> sent >> _ingest_orders() >> position >> etl
@dag( @dag(
+11 -4
View File
@@ -1,9 +1,10 @@
"""Создание и пересоздание прикладного мира. """Создание и пересоздание прикладного мира.
Compose поднимает службы, а этот граф создаёт всё прикладное поверх них: Compose поднимает службы, а этот граф создаёт всё прикладное поверх них:
топики, схему ClickHouse, данные стартовых дней и позицию модельного времени. топики, схему ClickHouse, данные стартовых дней, позицию модельного времени и
`world_initialize` безопасен для обычного `make up`; `world_recreate` — явная готовые слои. `world_initialize` безопасен для обычного `make up`;
операция обслуживания, которая сначала объявляет прежний мир недействительным. `world_recreate` — явная операция обслуживания, которая сначала объявляет
прежний мир недействительным.
Контракт состояний и доводы — ADR 0013 и спека жизненного цикла мира. Контракт состояний и доводы — ADR 0013 и спека жизненного цикла мира.
""" """
@@ -262,9 +263,15 @@ def world_initialize():
poke_interval=10, poke_interval=10,
) )
position = remember_starting_position() position = remember_starting_position()
etl = TriggerDagRunOperator(
task_id="trigger_etl_pipeline",
trigger_dag_id="etl_pipeline",
wait_for_completion=True,
poke_interval=10,
)
route >> [ready, topics] route >> [ready, topics]
chain(topics, *ddl_tasks, image, events, arrived, snapshots, orders, position) chain(topics, *ddl_tasks, image, events, arrived, snapshots, orders, position, etl)
@dag( @dag(
+25 -23
View File
@@ -6,92 +6,94 @@
{ {
"day": 0, "day": 0,
"date": "2026-06-01", "date": "2026-06-01",
"events": 50626, "events": 50628,
"sha256": "81ad7f402bdd4c8b56425f6439a024b9996e5ca95dd207d444290d20a70ed407", "sha256": "d09704ed99e45824e451e2f6bbbbd5c547df382f7a582699955010f3807eb0ee",
"orders": { "orders": {
"match": 197, "match": 192,
"cancelled": 20, "cancelled": 20,
"lost_event": 3,
"duplicate_event": 2,
"amount_delta": 6 "amount_delta": 6
} }
}, },
{ {
"day": 1, "day": 1,
"date": "2026-06-02", "date": "2026-06-02",
"events": 49722, "events": 49727,
"sha256": "5f7aa0301832008db612eff05899b1210af958c888ac2e9e89f4b7a552569eff" "sha256": "8005d2934eb353478e35331d7ae7ae6876c7934b2d9032fde92cf6ce86d6460f"
}, },
{ {
"day": 2, "day": 2,
"date": "2026-06-03", "date": "2026-06-03",
"events": 52361, "events": 52363,
"sha256": "10ac9ce65ca6dc171689edf78ef5cd785e8c8a80e54d236bf2b9d6dbf6997070" "sha256": "e13f6f3f3977386a94d0e63b6849675cb5d2486a277d559fd415923cfc82e53a"
}, },
{ {
"day": 3, "day": 3,
"date": "2026-06-04", "date": "2026-06-04",
"events": 53157, "events": 53159,
"sha256": "7bf4deadd8f69a6c479fceabfe2d1e8ea8db76919b10a8b9e780006708da31c3" "sha256": "91c713add62222adc3f89c04e6cc087d8c44e2385d14d0464e2c016b6952b9fd"
}, },
{ {
"day": 4, "day": 4,
"date": "2026-06-05", "date": "2026-06-05",
"events": 50078, "events": 50087,
"sha256": "b9e758fb72f937aa5ad1f2e90dee5ec147b16cfd89e49e51a00582f909458c29" "sha256": "b7fc27d73fc157a48bece0189c7983f6bc672f052e0703cd799b9774da4b904e"
}, },
{ {
"day": 5, "day": 5,
"date": "2026-06-06", "date": "2026-06-06",
"events": 47164, "events": 47169,
"sha256": "f08a9f9a439f1da9e4ad8d94b9f65b7f3329256e41446d22d900cc4825f2480c" "sha256": "82cb8017d53d6dee8438d3683b7a901f1ace139f3b8ae836215722bb580fe207"
}, },
{ {
"day": 6, "day": 6,
"date": "2026-06-07", "date": "2026-06-07",
"events": 48683, "events": 48686,
"sha256": "7e45d9dd59c902067fa6847d813669b1ab9a4ee88d2f192382577a5ed8d590a7" "sha256": "b50ad0fcc7d1ea0c637cfcba98e5c2b55d65215cffa3693bc77edeaa83e6c783"
}, },
{ {
"day": 7, "day": 7,
"date": "2026-06-08", "date": "2026-06-08",
"events": 49394, "events": 49394,
"sha256": "286ccc2451847ac8de215b5e902c328371021ad5502bd4afca0494b6f313aef7" "sha256": "358d51b36221d822960237702fa6327fb663a57cf49cc4415a859b16e5aa05a2"
} }
], ],
"snapshots": [ "snapshots": [
{ {
"day": 0, "day": 0,
"date": "2026-06-01", "date": "2026-06-01",
"sha256": "6c3e3c1c8689ec23f53f0953db1b941e6f010e9e56a74d8d017f40195d3dec17" "sha256": "93b39f261cbbe894402ebe0525460fed1809e5712639be4b73b79dac2b39f5bd"
}, },
{ {
"day": 1, "day": 1,
"date": "2026-06-02", "date": "2026-06-02",
"sha256": "9acb523cfd47f3d97d55122025852982ba5a1d8362036308ce72a4d9fb767f43" "sha256": "4fef382cd84868ccfb61a9d8610cdd26f9a749bbae8b20800c45e520e9ce414e"
}, },
{ {
"day": 2, "day": 2,
"date": "2026-06-03", "date": "2026-06-03",
"sha256": "b81482491c4c0b874ea84d5f3f05144100c906dacc059c068b331df258b97172" "sha256": "caf9ed2e77d2fcc99066a548dcba60f61f1ff0d4402de31457a06f23fd2d8306"
}, },
{ {
"day": 3, "day": 3,
"date": "2026-06-04", "date": "2026-06-04",
"sha256": "9d1ab326c4bf8b78c9fcaf7015d8029f9ad4cff5558b89c730eb428d175cc9ee" "sha256": "cdb9e4e00b57c04985866b5a851a8e2f23b3e9205ea131c7a5076672ee8b5669"
}, },
{ {
"day": 4, "day": 4,
"date": "2026-06-05", "date": "2026-06-05",
"sha256": "0e30d0e192e121121003c0cc9aaa55e24924afb983caff15ab105705fbf2bd00" "sha256": "c58d2c20fe3b4cee2973f34c322090dbb66d3793bfaab0337c22b53fb5a3ec7f"
}, },
{ {
"day": 5, "day": 5,
"date": "2026-06-06", "date": "2026-06-06",
"sha256": "9a2ad812fcff9a40a988f24f8aa4fc7a379873d173a95ddacd438592aed0bfb5" "sha256": "bbf28b1d375e4815695c72a5915a22a3c9b36195b97ec4cc7c739466dbe70b53"
}, },
{ {
"day": 6, "day": 6,
"date": "2026-06-07", "date": "2026-06-07",
"sha256": "e1706a27ff55de489a505489148c7a959329d95ad6597d92d3f78505f67bb221" "sha256": "8ca077576bcf014c0d492c7d7190e38c0e46ec95951ad840df93eb75ee5a00d3"
} }
] ]
} }
+7
View File
@@ -16,6 +16,13 @@
возвращает единицу — поведение проверено при исполнении #37. Зависимость возвращает единицу — поведение проверено при исполнении #37. Зависимость
включает результат подготовки в общий граф ожидания. включает результат подготовки в общий граф ожидания.
У каждого общего локального образа один блок `build`. Он находится у службы
инициализации. Остальные службы зависят от неё и используют тот же `image`.
`docker compose up --build` завершает сборку до запуска контейнеров, поэтому
одного блока достаточно. В Buildx 0.30.1 одинаковые блоки у нескольких служб
запускают параллельный экспорт одного тега. Иногда сборка завершается ошибкой
`image already exists`.
Для входа выбран SimpleAuthManager. У него нет команды создания пользователя, Для входа выбран SimpleAuthManager. У него нет команды создания пользователя,
поэтому `airflow-init` записывает пароль администратора из окружения в поэтому `airflow-init` записывает пароль администратора из окружения в
JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит
+21 -4
View File
@@ -19,16 +19,23 @@
Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел
памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и
о самой машине ничего не знает. Остальные `mem_limit` не меняются. о самой машине ничего не знает. На 1 августа остальные `mem_limit` не менялись.
Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются
статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`, статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`,
`AIRFLOW__CORE__PARALLELISM` и девять неизменённых `mem_limit`. Основания, `AIRFLOW__CORE__PARALLELISM` и восемь оставшихся `mem_limit`. Основания,
кроме отменённого, у них нет. Кто в них упрётся — меняет по первому кроме отменённого, у них нет. Кто в них упрётся — меняет по первому
свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus
сюда не входит: неделя истории — осознанный учебный выбор, и цена у него сюда не входит: неделя истории — осознанный учебный выбор, и цена у него
дисковая. дисковая.
27 августа таким пределом впервые стала коробка `airflow-scheduler`:
LocalExecutor исполняет в ней до четырех процессов задач одновременно, а
640 МиБ этого не вместили. Коробка поднята до 1,5 ГиБ. Ресурсные ограничения
из отдельных дагов удалены. Общий предел параллелизма уже задает Airflow.
`max_active_runs=1` остается там, где два запуска делили бы один донор и могли
повредить публикацию.
Ресурсный довод ADR 0001 отозван. Ресурсный довод ADR 0001 отозван.
## Почему ## Почему
@@ -94,8 +101,8 @@ ClickHouse сам понижает каждый до 512 МиБ, а на сте
заранее, и в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме заранее, и в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме
пределов, а работе с запасом. пределов, а работе с запасом.
Остальные девять лимитов не меняются, потому что ни один из них ни разу не На 1 августа остальные девять лимитов не менялись, потому что ни один из них
сработал. Снять их скопом — то же изменение без свидетельств, каким они были не сработал. Снять их скопом — то же изменение без свидетельств, каким они были
выставлены, только в обратную сторону. Лечение здесь не в том, чтобы стереть выставлены, только в обратную сторону. Лечение здесь не в том, чтобы стереть
числа, а в том, чтобы лишить их статуса закона: число без основания меняют, числа, а в том, чтобы лишить их статуса закона: число без основания меняют,
когда оно мешает, и не защищают как принятое решение. когда оно мешает, и не защищают как принятое решение.
@@ -151,3 +158,13 @@ to 2.00 GiB because the system has limited RAM` и такие же строки
Значение `max_server_memory_usage_to_ram_ratio` = 0,9 и то, что в cgroup предел Значение `max_server_memory_usage_to_ram_ratio` = 0,9 и то, что в cgroup предел
считается от коробки, а не от машины, сверены по документации ClickHouse через считается от коробки, а не от машины, сверены по документации ClickHouse через
MCP Context7. MCP Context7.
`airflow-scheduler` проверен отдельно 27 августа 2026 года. На свежем
контейнере две попытки запустить четыре DQ-сверки параллельно в коробке
640 МиБ дошли ровно до `memory.max` и дали `oom_kill=1`: сначала при выгрузке
57 567 ключей сессий в Python, затем и после переноса дневной агрегации в
ClickHouse. В коробке 1 ГиБ сам `dq_check` прошел, но снова коснулся верхней
границы (`memory.events.max=57`). Полный `etl_pipeline` без локальных
ограничений задач в коробке 1,5 ГиБ завершился успешно: пик - 1 013 760 000
байт, `memory.events.max=0`, `oom_kill=0`. Docker не резервирует разницу между
пиком и пределом, поэтому этот запас не увеличивает расход памяти в покое.
+8 -6
View File
@@ -53,11 +53,13 @@
Стенд учит не администрированию доступа, а чтению отказа в правах: человек Стенд учит не администрированию доступа, а чтению отказа в правах: человек
должен узнать в бою картину, которую видел здесь, и понимать, чего просить. должен узнать в бою картину, которую видел здесь, и понимать, чего просить.
Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из
описания. Поэтому пользователей мало, имена у них говорящие, а `bi` отделён от описания. Поэтому пользователей мало, имена у них говорящие. В бою BI обычно
STG и от записи. Один пользователь может сверить всю цепочку DM → DDS → ODS, читает витрины и модельный слой. ODS ему могут открыть для расследования или
не получая доступа к сырью и изменению данных. Справочники видны всем читателям сверки. Стенд оставляет этот доступ постоянным, но сохраняет две крупные
и никому на запись: зона входит в хранилище сбоку, и её содержимым владеет файл границы: `bi` отделён от STG и от записи. Один пользователь может сверить всю
репозитория, а не слой. цепочку DM → DDS → ODS, не получая доступа к сырью и изменению данных.
Справочники видны всем читателям и никому на запись: зона входит в хранилище
сбоку, и её содержимым владеет файл репозитория, а не слой.
Ограничить `bi` одними `_v` поверх `Distributed` можно только отдельным Ограничить `bi` одними `_v` поверх `Distributed` можно только отдельным
маршрутом, учётной записью или копией данных. Эта механика не даёт нового урока. маршрутом, учётной записью или копией данных. Эта механика не даёт нового урока.
@@ -200,7 +202,7 @@ ClickHouse через MCP Context7 (#114).
Расширение `bi_reader` проверено на живом стенде 25 августа 2026 года (#150): Расширение `bi_reader` проверено на живом стенде 25 августа 2026 года (#150):
- `SHOW GRANTS FOR bi_reader` содержит `SELECT` на ODS, DDS и DM; - `SHOW GRANTS FOR bi_reader` содержит `SELECT` на ODS, DDS и DM;
- `bi` прочитал одинаковые 401185 строк из `ods.event_v` и `dds.event_v`; - `bi` прочитал одинаковое число строк из `ods.event_v` и `dds.event_v`;
- чтение `stg.hits_raw_rep` завершилось ошибкой `ACCESS_DENIED`; - чтение `stg.hits_raw_rep` завершилось ошибкой `ACCESS_DENIED`;
- представление над `Distributed` с `SQL SECURITY DEFINER` на удалённой ноде - представление над `Distributed` с `SQL SECURITY DEFINER` на удалённой ноде
сохранило исходного пользователя `bi`. Причина и отклонённые варианты сохранило исходного пользователя `bi`. Причина и отклонённые варианты
+3 -1
View File
@@ -1,7 +1,9 @@
# ADR 0010. Заказы в ODS: версии сущности вместо подмены слепка # ADR 0010. Заказы в ODS: версии сущности вместо подмены слепка
Дата: 16 августа 2026 года. Статус: принято. Частично заменяет Дата: 16 августа 2026 года. Статус: принято. Частично заменяет
[ADR 0008](0008-order-ingestion.md). [ADR 0008](0008-order-ingestion.md). Колонка версии пересмотрена
[ADR 0014](0014-order-version-snapshot-date.md): вместо `updated_at` —
`snapshot_date`.
## Решение ## Решение
+9 -6
View File
@@ -25,9 +25,11 @@ Kafka, DDL прикладных баз ClickHouse, стартовыми данн
`world_recreate` ставит `-1` до первого удаления и `0` только после успешной `world_recreate` ставит `-1` до первого удаления и `0` только после успешной
очистки. `world_initialize` принимает отсутствие позиции или `0`, а значение очистки. `world_initialize` принимает отсутствие позиции или `0`, а значение
`8` и больше считает готовым миром. Позиция становится `8` последней задачей, `8` и больше считает готовым миром. После генерации стартовых дней и успешного
после генерации стартовых дней и успешного приёма обоих источников. Обычные приема обоих источников позиция становится `8`; затем инициализация последним
даги движения мира работают только с готовой позицией. шагом ждет дневной конвейер `etl_pipeline`. При отказе преобразований мир
остается готовым на позиции `8`, а конвейер после исправления запускают рукой.
Обычные даги движения мира работают только с готовой позицией.
## Почему ## Почему
@@ -39,9 +41,10 @@ Kafka, DDL прикладных баз ClickHouse, стартовыми данн
Kafka сообщения и офсеты прежнего мира. Kafka сообщения и офсеты прежнего мира.
Airflow уже ведёт последующие дни и приём заказов. Отдав ему начало той же оси, Airflow уже ведёт последующие дни и приём заказов. Отдав ему начало той же оси,
получаем один наблюдаемый процесс и одну точку успеха: зелёный получаем один наблюдаемый процесс и одну точку успеха: зеленая ветка создания в
`world_initialize` означает, что схема создана, стартовый мир принят и его `world_initialize` подтверждает, что схема создана, стартовый мир принят, его
следующий день известен. следующий день известен, а слои DDS и DM собраны. Короткая ветка готового мира
по-прежнему подтверждает позицию и ничего не меняет.
## Что это решение уточняет ## Что это решение уточняет
@@ -0,0 +1,96 @@
# ADR 0014. Версия заказа в ODS: дата слепка
Дата: 26 августа 2026 года. Статус: принято. Пересматривает колонку версии
из [ADR 0010](0010-order-versions-in-ods.md); остальные решения того ADR в
силе. Развилка разобрана в #163, реализация — #165.
## Решение
`ods.order` хранит версии заказа в `ReplacingMergeTree(snapshot_date)`: при
схлопывании выживает строка позднейшего слепка, который нёс заказ. Формула
решения: у полного слепка версия — сам слепок; истина — то, что источник
сказал последним.
Ключ сущности, партиция и шардирование не меняются. `updated_at` остаётся
деловым полем, и деловой ответ слоя от смены версии не зависит: на
канонических слепках выбор по дате слепка побайтово совпадает с выбором по
деловой метке, потому что метка монотонна по слепкам, а слепок дня — чистая
функция зерна и дня (тесты генератора
`test_the_state_is_read_at_the_boundary_of_the_day` и
`test_two_runs_give_the_same_snapshot`).
Решение покупает две вещи.
- **Детерминизм слоя.** Разные слепки несут разные даты — ничьей между
содержательно разными строками не бывает. При версии `updated_at` доставки
неизменившегося заказа равны по версии, и выжившая строка — жребий: у 86
заказов стартового мира выживала строка серединного слепка, притом что у
всех 3034 пар строк с равным `updated_at` деловые поля совпадали побайтово
(замеры #137). Документация ClickHouse обещает при ничьей победу последней
вставленной строки — замер это опроверг, полагаться на неё было бы ошибкой.
- **Правдивый ярлык.** `snapshot_date` выжившей строки — последний слепок,
который нёс заказ. Объём пересборки DDS читается прямо из данных, причём
без `FINAL`: слияние сохраняет максимум даты по построению. Константа
окна K уходит со стороны стенда вместе с риском дрейфа её двух копий
(ревью #151 ставило этому риску MAJOR).
## Ответ доводу ADR 0010
ADR 0010 отверг семейство «публикацию решает дата наблюдения»: у потребителя
нет признака полноты слепка. Довод целил в подмену партиции — ей полнота
нужна, потому что неполная партиция стёрла бы уже принятые версии. Колонке
версии полнота не нужна: она сравнивает свежесть строк одного заказа, и
недоехавшая строка просто оставляет в силе предыдущую — ровно как при версии
по деловой метке.
## Границы допущения
- **Дисциплина слепков.** Слепок с поздней датой не несёт состояния старше.
Источник, собирающий слепок из отставшей реплики, это ломает; модельный
мир — нет: см. тесты выше.
- **Одна дата — один слепок.** Ограничение вшито в движок: вторая
внутридневная выгрузка дала бы ничью уже на деловых полях. Это условие
контракта источника, его место — в границе строгого приёма
([приём заказов](../architecture/orders/ingestion.md)).
- **Окно наследует потерю строк.** Частичный отказ вставки после коммита
офсетов может унести старший день последнего слепка — выведенное окно
молча сузится. Лекарство прежнее, сверка слоя с источником в контуре DQ
([конвейер](../architecture/etl.md), «Чего конвейер о себе не знает»).
- **Тихая правка лечится только внутри окна.** Строку, изменённую без
сдвига `updated_at`, перекроет следующий слепок; замёрзший заказ источник
больше не показывает, там лечит лишь сверка полей.
- **Повторная доставка слепка.** Байты совпадают, жребий остаётся только на
`_load_id` и `_load_ts`; ярлык даты детерминирован.
- **Правка живого мира не подхватится.** DDL исполняется при рождении мира,
`ALTER` колонку версии не меняет: решение вступает в силу пересозданием
хранилища (`make rebuild-storage`).
## Отклонённые варианты
- **Оставить как есть, сверку копий K отдать DQ.** Лотерея ярлыков остаётся
задокументированным грехом, окно — константой с риском дрейфа.
- **Убрать координаты доставки из ODS.** Наказывает поле за вопрос, на
который оно не бралось отвечать: расследованию (связка годных строк,
ошибок и сырья) координаты служили честно.
- **Дедупликация на вставке.** Детерминизм «обычно», не по построению:
анти-джойн с собственной целью не атомарен, лаг реплик протаскивает дубли.
Приём теряет урок «разобрал и вставил», а ярлык со смыслом «первый
носитель» хуже для обеих ролей поля: окну нужен последний носитель, и
сырьё живо скорее у него.
- **Склейка `(updated_at, snapshot_date)` в одну колонку версии.** Движок
берёт одну колонку, склейка требует битовой упаковки. Робастнее к
недисциплинированному источнику, но платит читаемостью DDL за защиту от
сценариев, которых модельный мир не производит.
- **Склейка `(snapshot_date, updated_at)`.** Путь отступления: если у
источника появятся переиздания слепка одной даты, версия дорастает до
этой формы. От правок без сдвига метки внутри одной даты не спасает и она.
## Связанные решения
- [ADR 0010](0010-order-versions-in-ods.md) — версии заказа в ODS; здесь
пересмотрена только колонка версии.
- [Приём заказов](../architecture/orders/ingestion.md) — граница строгого
приёма и четыре координаты строки.
- [Модель заказа в DDS](../architecture/dds/order.md) — читатель окна
пересборки.
- Развилка, веер и два холодных ревью — #163.
+36
View File
@@ -0,0 +1,36 @@
# ADR 0015. Модельный день считается в часовом поясе счетчика
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
## Решение
Модельные сутки считаются в часовом поясе счетчика UTC+4. `EventDate` обозначает
дату в этом поясе, а `UTCEventTime` — абсолютную метку времени. Граница
модельного дня приходится на полночь счетчика.
Суточная волна посетителя привязана к его местному времени. У гостя из другого
пояса она сдвигается на разницу с поясом счетчика, но общая граница дня остается
прежней.
## Почему
Так устроена выгрузка Метрики: дата события уже посчитана в поясе счетчика.
Полночь в этом поясе не разрезает утренние сессии в регионе мира. Регион можно
выбирать независимо от технического пояса счетчика.
Для всего потока дня нужна одна граница суток. Местное время гостя описывает
поведение в пределах этих суток, а отдельная дата для каждого посетителя не
нужна.
## Отклонено
В разделе 9 отклоненные варианты не зафиксированы.
## Следствия
У ночных событий `toDate(UTCEventTime) != EventDate`. Хранилище берет день из
готового `EventDate`, а не выводит его из абсолютной метки. Поворот суточной
волны слегка размывает общий пик активности гостей из разных поясов.
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
раздел 9.
+28
View File
@@ -0,0 +1,28 @@
# ADR 0016. Паспорт куки хранится в плане состава на всю ее жизнь
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
## Решение
Браузер, операционная система, устройство, экран и город принадлежат куке на всю
ее жизнь. План состава выбирает и хранит паспорт, а день только переносит его в
поля событий.
Две куки одного человека получают один город и разные устройства. Броски
паспорта идут после прежних бросков состава, поэтому прежние измерения не
сдвинулись.
## Почему
Кука представляет браузер на устройстве, поэтому ее паспорт не должен меняться
между днями. Только план состава связывает две куки с одним человеком. Поэтому
он может задать им один город и разные устройства.
## Отклонено
- Выводить паспорт арифметикой из `ClientID`: этот идентификатор не несет
сведений о двухкуковой паре, поэтому по нему нельзя задать двум кукам одного
человека один город и разные устройства.
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
раздел 9.
+34
View File
@@ -0,0 +1,34 @@
# ADR 0017. Номер заказа объединяет две проекции покупки
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #40.
## Решение
Номер заказа включает день по модельному времени и порядковый номер покупки в
этот день, например `20260603-0042`. Событийная сторона пишет его в
`purchaseID`, бэкенд — в `order_id`; обе стороны берут один номер из плана
покупки.
Все покупки, попавшие в поток дня, нумеруются в порядке событий и до любых
потерь следующих этапов.
## Почему
По читаемому номеру сразу виден день заказа. Один номер соединяет две проекции
одной покупки. Генераторы кликстрима и бэкенда не согласуют его отдельно.
Присвоение до потерь сохраняет номера остальных заказов: исчезновение одного
события не должно перенумеровывать чужие покупки и разрывать связь с бэкендом.
## Отклонено
- Присваивать номера после внесения потерь: одна потеря сдвинула бы все
следующие номера за день и разорвала бы связь уже созданных проекций.
## Следствия
`purchaseID` и `order_id` изначально совпадают. Из-за расхождения событие может
исчезнуть после присвоения номера, но номера сохранившихся заказов не меняются.
Решение принято при исполнении тикета #40 и зафиксировано в спеке этапа 2,
раздел 9.
+16 -9
View File
@@ -45,6 +45,11 @@
своё имя здесь создало бы третий синоним. `client_timezone` тоже остаётся — своё имя здесь создало бы третий синоним. `client_timezone` тоже остаётся —
это атрибут посетителя, а не линза времени. это атрибут посетителя, а не линза времени.
`purchase_revenue` сохраняет смысл поля источника. Тип денег нормализуется в
ODS; точный тип и проверка описаны в разделе
[«Актуальные события»](../storage.md#актуальные-события). DDS только
переименовывает уже нормализованную колонку.
## Расшифровки ## Расшифровки
Одна: `device_category` — коды 1–4 в строки `desktop` / `mobile` / Одна: `device_category` — коды 1–4 в строки `desktop` / `mobile` /
@@ -52,11 +57,15 @@
стенда несёт число — принятое отступление: спека, раздел 1.1), контракт возвращает стенда несёт число — принятое отступление: спека, раздел 1.1), контракт возвращает
ему боевой вид. ему боевой вид.
Другие коды остаются кодами. `goals_reached` — справочника целей на стенде Значения `device_category` 1–4 получают имена, остальные сохраняются
нет, id и есть язык. Регионы в расшифровке не нуждаются: выгрузка несёт десятичной строкой. Новый код остаётся видимым, пока у него не появится имя в
названия рядом с id (`region_city`, `region_country`), словарь ничего бы не договоре.
раскрыл. Идея дать менти собрать справочник регионов самому ушла в лабы —
issue #143. Остальные кодированные атрибуты не расшифровываются. Для `goals_reached`
справочника целей на стенде нет, id и есть язык. Регионы в расшифровке не
нуждаются: выгрузка несёт названия рядом с id (`region_city`,
`region_country`), словарь ничего бы не раскрыл. Идея дать менти собрать
справочник регионов самому ушла в лабы — issue #143.
## Что скрыто ## Что скрыто
@@ -85,10 +94,8 @@ issue #143.
- Сборка сессий: ключи нарезки (`client_id`, `event_time`, - Сборка сессий: ключи нарезки (`client_id`, `event_time`,
`event_date`), тип события, страница, UTM, паспорт куки — категория `event_date`), тип события, страница, UTM, паспорт куки — категория
устройства приходит уже расшифрованной. устройства приходит уже расшифрованной.
- Витрины `dm.events_enriched_v`, `dm.top_pages_daily_v`, - Витрины `dm.events_enriched_v`, `dm.top_pages_daily_v` и
`dm.utm_effectiveness_v` (`declared_revenue` — из `purchase_revenue`), `dm.utm_effectiveness_v` (`declared_revenue` берется из `purchase_revenue`).
`dm.daily_traffic_v` (пара «посетители / известные» через
`dds.identity_map_v`).
- Событийная сторона сверки `dm.purchase_vs_orders_v`: `purchase_id`, - Событийная сторона сверки `dm.purchase_vs_orders_v`: `purchase_id`,
`purchase_revenue`, `event_date`. `purchase_revenue`, `event_date`.
- DQ-сверка сессий с эталоном читает не его, а `ods.event_v` — - DQ-сверка сессий с эталоном читает не его, а `ods.event_v` —
+11 -12
View File
@@ -65,16 +65,15 @@ ODS: это единственный устойчивый ключ сущнос
одинаковы у всех строк дня и отвечают на вопрос «каким запуском и когда одинаковы у всех строк дня и отвечают на вопрос «каким запуском и когда
собран этот день». собран этот день».
Ярлыки ODS — `snapshot_date`, `_load_id` приёма, `_load_ts` прибытия — в Ярлыки ODS (`snapshot_date`, `_load_id` приёма, `_load_ts` прибытия) в модель
модель не переезжают. Версию заказа они не задают, а физически выжившие их не переезжают. Они описывают наблюдение и доставку строки в ODS. Модель DDS
значения — жребий: у строк с равным `updated_at` деловые поля совпадают хранит текущее деловое состояние заказа. В ODS `snapshot_date` задаёт версию и
побайтово, и какая переживёт схлопывание — произвол (замер — называет последний слепок, в котором источник показал заказ
[приём заказов](../orders/ingestion.md), «Что проверено»). Перенесённый ([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Дату
ярлык выглядел бы фактом, оставаясь жребием; лживая координата хуже наблюдения и координаты доставки читатель спрашивает у ODS по
отсутствующей. Отсюда правило слоя: **служебные колонки — координаты `order_id`. Правило слоя:
собственной загрузки слоя; чужие координаты спрашиваются у их слоя по ключу **служебные колонки — координаты собственной загрузки слоя; чужие координаты
сущности**. Этим закрыто наследство #85: читатель у чужих ярлыков выше ODS спрашиваются у их слоя по ключу сущности**.
не нашёлся и при названных читателях модели.
Происшествие качества данных расследуется цепочкой, где каждый шаг Происшествие качества данных расследуется цепочкой, где каждый шаг
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой
@@ -109,8 +108,8 @@ ODS: это единственный устойчивый ключ сущнос
позиций и пересчёт времён при каждом чтении собирали бы модель заново на позиций и пересчёт времён при каждом чтении собирали бы модель заново на
лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу
не противоречит: оно ничего заново не вычисляет. не противоречит: оно ничего заново не вычисляет.
- **`ReplacingMergeTree(updated_at)` ещё раз.** Повторил бы механику ODS - **`ReplacingMergeTree` ещё раз.** Повторил бы механику ODS вторым разом:
вторым разом: физически хранил бы устаревшие состояния, вернул бы `FINAL` физически хранил бы устаревшие состояния, вернул бы `FINAL`
и обёртку-представление — а урок версий менти уже отыграл. и обёртку-представление — а урок версий менти уже отыграл.
- **Отдельная сущность `dds.order_item`.** Вторая таблица, второй вопрос - **Отдельная сущность `dds.order_item`.** Вторая таблица, второй вопрос
шардирования и `JOIN` там, где хватает `ARRAY JOIN`; нормализация из шардирования и `JOIN` там, где хватает `ARRAY JOIN`; нормализация из
+18 -15
View File
@@ -35,10 +35,9 @@
ломают. ломают.
`session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка: `session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка:
витрины сессий читают `dds.session_v`, их зерно совпадает с зерном модели, и в в соединениях он не участвует — события его не несут, мост к заказам живёт
соединениях id не участвует — события его не несут, мост к заказам живёт на на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со
событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со стендовой и стендовой и назвать расхождение одним числом) и разбор DQ-расхождений.
назвать расхождение одним числом) и разбор DQ-расхождений.
## Состав строки ## Состав строки
@@ -73,15 +72,18 @@
шарде, поэтому сессионизация локальна по природе — капкан смены ключа при шарде, поэтому сессионизация локальна по природе — капкан смены ключа при
вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет. вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет.
Сборка — дневной батч после прихода слепка заказов: хранилище выше ODS по Сборка — дневной батч: хранилище выше ODS по смыслу батчевое, раз второй
смыслу батчевое, раз второй источник приходит раз в сутки. К этому моменту источник приходит раз в сутки. Собирается день, который мир уже прожил, и
события дня полны, и сессии только что прожитого дня собираются один раз и собирается один раз и целиком; какой день прожит, слой спрашивает у позиции
целиком; идемпотентность держит замена партиции. В отличие от заказов, где мира ([дневной конвейер](../etl.md), «Объём прогона»). Идемпотентность держит
батч пересобирает до K «дышащих» дней, у сессий пересобирается только одна замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих»
партиция: день самодостаточен и замерзает сразу (правило спеки, раздел 6). дней, у сессии дышащего окна нет: прожитый день самодостаточен (правило
Внутри живого дня модель сессии про сегодня честно молчит — «сегодня» спеки, раздел 6). Второй раз он соберётся только
смотрят через `dds.event_v`; сессия и по смыслу не финальна, пока день не вынужденно — когда упавшая замена оставила позади дыру и хвост от неё накрыл
прожит. соседние дни. Дней в одном прогоне при этом бывает несколько — столько,
сколько мир успел прожить без слоя. Внутри живого дня
модель сессии про сегодня честно молчит — «сегодня» смотрят через
`dds.event_v`; сессия и по смыслу не финальна, пока день не прожит.
Публичная поверхность — `dds.session_v`: обычное представление с явным Публичная поверхность — `dds.session_v`: обычное представление с явным
списком деловых и собственных служебных колонок над `dds.session_dist`. списком деловых и собственных служебных колонок над `dds.session_dist`.
@@ -107,8 +109,9 @@
## Читатели ## Читатели
- Витрина обзора сессий `session_overview_v`: длительности, глубина, - Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
вход/выход; читает `dds.session_v`, зерно совпадает (#135). срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
в ее [ТЗ](../../labs/session-overview.md) (#135).
- DQ-сверка с эталоном (см. выше). - DQ-сверка с эталоном (см. выше).
- Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда, - Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда,
потом сравнивает — с эталоном и со стендовой. потом сравнивает — с эталоном и со стендовой.
+153 -46
View File
@@ -31,22 +31,24 @@
витрины, на него ссылаются учебные задания; витрины, на него ссылаются учебные задания;
- `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ, - `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ,
шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`; шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`;
- `daily_traffic` — пара «посетители» / «известные пользователи», локальное - `daily_traffic` — пара «посетители» / «известные пользователи», обогащение
обогащение [картой идентичностей](dds/identity.md); [картой идентичностей](dds/identity.md);
- `dq_summary` — сводка проверок качества (ниже). - `dq_summary` — сводка проверок качества (ниже).
Остальные витрины спеки — учебные задания: менти строит их сам по ТЗ с Остальные витрины спеки — учебные задания: менти строит их сам по ТЗ с
образцом. Разминка — представление `events_enriched_v` (зерно события), образцом. Разминка — представление `events_enriched_v` (зерно события),
затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`, затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`,
последней — самая содержательная `utm_effectiveness` с уроком `declared_*`. последней — самая содержательная `utm_effectiveness` с уроком `declared_*`.
Построенная витрина встаёт в конвейер руками менти: финальный пункт Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
каждого ТЗ — добавить задачу своей витрины в `dm_transform`. До этого каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
наравне со стендовыми. наравне со стендовыми. У разминочного представления такого пункта нет:
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
от выполненных домашек. Расширение дашборда визуализациями по своим от выполненных домашек. Свой дашборд по витринам менти —
витринам — отдельная домашка после этапа 8. [необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
стендового дашборда — в [доке зоны Superset](superset.md).
## Ритм пересборки ## Ритм пересборки
@@ -55,8 +57,8 @@
- **от событий и сессий** — день замерзает сразу; пересборка догоняет - **от событий и сессий** — день замерзает сразу; пересборка догоняет
хвост несобранных дней заменой дневных партиций; хвост несобранных дней заменой дневных партиций;
- **от заказов** — день дышит окном K и закрывается; пересборка заменяет - **от заказов** — день дышит, пока входит в последний слепок; пересборка
партиции окна, дни за ним закрыты по построению; заменяет партиции этого отрезка, дни за ним закрыты по построению;
- **от карты идентичностей** — не замерзает никогда: склейка - **от карты идентичностей** — не замерзает никогда: склейка
ретроспективна, покупка сегодня делает куку известной во всех её прошлых ретроспективна, покупка сегодня делает куку известной во всех её прошлых
днях, а карта пересчитывается целиком. днях, а карта пересчитывается целиком.
@@ -64,61 +66,166 @@
Поэтому `daily_traffic` наследует у своего источника и ритм, и механику: Поэтому `daily_traffic` наследует у своего источника и ритм, и механику:
полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`, полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`,
как у самой карты. Дневных партиций у неё нет: партиция — единица замены, как у самой карты. Дневных партиций у неё нет: партиция — единица замены,
а эта витрина заменяется только целиком. Пересчёт дешёв: а эта витрина заменяется только целиком. На стенде такая работа остается
витрина читает сессии и карту локальным соединением по ключу ко-локации. быстрой; витрина читает сессии и карту через их публичные договоры.
Пересчитывать точечно только затронутые дни не из чего: для этого надо Пересчитывать точечно только затронутые дни не из чего: для этого надо
знать, какие связки новые, а полный пересчёт карты этот след затирает — знать, какие связки новые, а полный пересчёт карты этот след затирает —
восстановление следа оставлено лабе повышенной сложности (issue #149). восстановление следа оставлено лабе повышенной сложности (issue #149).
У `purchase_vs_orders` два источника и одна непрерывная пересборка. Заказная
сторона задает изменяемое окно последним принятым слепком ODS, событийная —
последний прожитый день с `purchase`. Левая граница возвращает первую дыру и
самый ранний день с `awaiting_order`: предварительная строка уже есть в
витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница
доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный
разгон длиннее K и не оставляет старое ожидание замороженным.
## Витрины стенда
Публичные представления перечисляют деловые колонки и координаты запуска
`_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не
попадет.
### `dm.revenue_daily_v`
Зерно: день заказа × категория товара. Витрина читает только оплаченные
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает
категорию артикула через `dic.products`. Один заказ с несколькими позициями
категории считается в `orders` один раз. `units` складывает количество единиц,
`revenue` складывает цену позиции × количество. Скидка, доставка и итог заказа
между категориями не делятся. `aov` равен `revenue / orders` в том же срезе и
хранит два знака после запятой.
Первый запуск строит всю доступную историю. Следующие берут границы дней из
последнего слепка ODS: только источник знает, какие даты еще может изменить.
Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ
не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS
отвечает только на вопрос об объеме.
`dm.revenue_daily_rep` нарезана по `report_date`; день собирается в
таблице-доноре и заменяется через `REPLACE PARTITION`. Цель и донор шардируются
по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а
шесть категорий стартового мира близки по объему. Более сложный составной ключ
распределил бы эту маленькую таблицу без нового урока.
### `dm.purchase_vs_orders_v`
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную
сумму, а отдельный счет событий оставляет класс `duplicate_event`
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
([исследование](../research/2026-08-22-distributed-join.md)).
Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При
`join_use_nulls = 0` ClickHouse заполнил бы ее нулем или пустой строкой и скрыл
отсутствие источника. Клиентская `declared_revenue` и серверная `items_total`
сравниваются как `Decimal(18, 2)`. Классы проверяются по порядку:
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
события остается отмененным, а не потерянным.
Витрина нарезана по `order_day`, цель и донор шардируются по
`cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и
заменяется по дням. Первичная сборка охватывает всю историю обоих источников;
следующие прогоны идут по двустороннему ритму, описанному выше.
### `dm.daily_traffic_v`
В строке один день. `visitors` считает разные `client_id` из
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
Несколько кук одного человека увеличивают `visitors`, но дают одного
`known_user`, поэтому `known_users <= visitors`.
Сессии и карта лежат по `client_id`, но публичные `_v` скрывают
`Distributed`-таблицы от локальной подстановки. На ClickHouse 26.3
`EXPLAIN PLAN` показал чтение обоих шардов на инициаторе: настройка
`distributed_product_mode = 'local'` локального соединения не дала. Поэтому
витрина использует явный `GLOBAL LEFT JOIN` через публичные договоры, а не
подменяет их физическими таблицами. Это дороже по сети, зато граница слоев
остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск
пишет полную историю новой версией в `ReplacingMergeTree(_load_ts)`, а
публичное представление выбирает текущие строки через `FINAL`.
## `dm.dq_summary` ## `dm.dq_summary`
Зерно — «день данных × проверка»: строка отвечает, каково качество дня Зерно публичного `dm.dq_summary_v` — «день данных × атомарная проверка».
сейчас, а не сколько раз его проверяли, — история прогонов и так живёт в Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
Airflow. Партиция — день данных; прогон звена DQ заменяет партиции Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
проверенных дней через `REPLACE PARTITION`. Обещанный спекой перевод с
`TRUNCATE+INSERT` выполняется той же механикой замены, что и у витрин, — Деловые колонки сводки:
ничего отдельного учить не надо.
- `data_date` — проверяемый день;
- `check_name` — устойчивое имя инварианта;
- `reference_rows` — число деловых ключей на эталонной стороне;
- `actual_rows` — число деловых ключей в проверяемом объекте;
- `failed_rows` — число отсутствующих или различающихся ключей;
- `status` — `pass` при нуле нарушений, иначе `fail`.
Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление
перечисляет колонки явно. Порогов и промежуточного состояния нет.
## Звено DQ конвейера ## Звено DQ конвейера
Встаёт в `etl_pipeline` после DM ([конвейер](etl.md)) и проверяет только Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
объекты стенда: атомарных договоров:
- счётная сверка сессий с эталоном `VisitID` — кука × день, эталон читается - `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
из `ods.event_v` ([модель сессии](dds/session.md)); `uniqExact(VisitID)` из `ods.event_v`, объект — число строк
- счётчики классов расхождений против описи мира — по дням с закрытым `dds.session_v`;
окном. Проверка техническая, не урок DQ: её оракул — опись генератора, - `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
а у настоящего магазина оракула нет. Обещание узкое и честное: до `orders` в описи мира, объект — число заказов класса в
витрин доехал тот мир, который родился, — одна растяжка через весь `dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
путь вместо сторожей на каждом шаге. Верно ли сам мир рождён, она `duplicate_event` и `amount_delta` только для покрытых описью закрытых дней;
не видит: опись считает тот же генератор; - `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
- счётные сверки витрин стенда с их DDS-источниками. `orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
поля сверки сравниваются с независимой сборкой из `dds.event_v` и
`dds.order_v`;
- `daily_traffic_vs_dds`: ключ — день. `visitors` и `known_users`
сравниваются с прямыми агрегатами `dds.session_v` и
`dds.identity_map_v`.
Результаты ложатся в `dm.dq_summary`. Звено — не только сторож конвейера, Область каждой проверки выводится из объединения сторон. Полностью потерянный
но и экспонат: менти видит, как контроль качества устроен в бою — по день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут
расписанию, с историей, рядом с трансформациями. скомпенсировать друг друга общим итогом: `failed_rows` считается после
сопоставления деловых ключей.
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет
сам генератор: у настоящего магазина такого оракула нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow
только сводку и малый срез диагностики. Техническая сверка с описью читает
небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их
успеха даг заменяет проверенные дневные партиции. Затем именованные задачи
читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
сохраниться до красного завершения, а ошибка сборки не затирает прежний
результат. Красная задача печатает не больше двадцати нарушенных ключей за
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
business_key`.
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
Такой день удаляется из сводки отдельно: старая красная строка не должна
превращаться в историю попыток.
## Даг самопроверки ## Даг самопроверки
Домашки контролирует отдельный даг без расписания: менти запускает его Домашки контролирует `homework_check` — отдельный даг без расписания: менти
рукой. Задача витрины красная, пока витрины нет или счёт не сходится, и запускает его рукой. По задаче на каждое из
зелёная, когда построено верно; сообщение об ошибке говорит, что не так и [пяти заданий по витринам](../labs/README.md).
куда смотреть. Проверки — инварианты против DDS-источников, золотые Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
константы не прибиваются. В `dq_summary` даг не пишет: он инструмент лабы, построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
а не история качества конвейера. золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
лабы, а не история качества конвейера.
Приём проверен на соседнем учебном стенде Airflow + Greenplum: Приём проверен на соседнем учебном стенде Airflow + Greenplum:
валидационный даг с группами проверок по слоям давал менти обратную связь валидационный даг с группами проверок по слоям давал менти обратную связь
по каждому шагу домашки, не трогая рабочий конвейер. по каждому шагу домашки, не трогая рабочий конвейер.
## Остаётся нарезке
- Имена задач `dm_transform`, звена DQ и дага самопроверки.
- Ключи шардирования остальных материализованных витрин — вместе с их
DDL; правило партиции-донора — в [доке хранилища](storage.md).
- Тексты учебных заданий и точный список проверок обоих контуров.
- Механика сверки A+C в SQL — рамка в спеке, разделы 4 и 7.
## Отклонённые варианты ## Отклонённые варианты
- **Весь слой представлениями.** Дёшево и всегда свежо, но главный - **Весь слой представлениями.** Дёшево и всегда свежо, но главный
@@ -136,7 +243,7 @@ Airflow. Партиция — день данных; прогон звена DQ
расшифровывать. Красное «ещё не построено» честнее. расшифровывать. Красное «ещё не построено» честнее.
- **Самопроверка пишет в `dq_summary`.** Попытки менти смешались бы с - **Самопроверка пишет в `dq_summary`.** Попытки менти смешались бы с
историей качества конвейера. историей качества конвейера.
- **Окно K для `daily_traffic` — прошлое замерзает.** Дни за окном хранили - **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
бы число известных на момент закрытия — витрина тихо расходится с картой бы число известных на момент закрытия — витрина тихо расходится с картой
и протаскивает обратно «знание на дату», отвергнутое развилкой карты и протаскивает обратно «знание на дату», отвергнутое развилкой карты
(#133). (#133).
+87 -20
View File
@@ -5,17 +5,20 @@
движки, что именно пересобирается — живут в доках слоя движки, что именно пересобирается — живут в доках слоя
([карта модели DDS](dds/README.md)); здесь — только конвейер. ([карта модели DDS](dds/README.md)); здесь — только конвейер.
Спека обещала `etl_pipeline` этапом 5 — он понадобился раньше. Конвейер Дневная сборка вперёд появляется на этапе 4 и остаётся основным режимом
рождается этапом 4, и временного в нём ничего нет: дневная сборка вперёд конвейера. На этапе 5 к ней добавляется партиционная переобработка: запуск
останется и после. Этапу 5 достаётся его законный предмет — партиционная произвольного прошлого дня по команде.
переобработка, умение переиграть произвольный прошлый день по команде.
## Форма ## Форма
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы
у него нет: он дёргает слойные даги последовательно и ждёт каждый — у него нет: он дёргает слойные даги последовательно и ждёт каждый —
`dds_transform`, затем `dm_transform`. Контур DQ этапа 4 встаёт его же `dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в
звеном после DM; состав звена — в [доке слоя DM](dm.md). [доке слоя DM](dm.md).
Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по
ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что
оператор опрашивает точный созданный им прогон и краснеет вслед за потомком.
Слойные даги устроены по образцу `orders_ingest`: без расписания, один Слойные даги устроены по образцу `orders_ingest`: без расписания, один
активный прогон, запускаются триггером конвейера или рукой при разборе активный прогон, запускаются триггером конвейера или рукой при разборе
@@ -23,6 +26,14 @@
## Три пути запуска ## Три пути запуска
```text
world_initialize ─┐
world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transform ─> dq_check
запуск рукой ─────┘
world_next_day ─────> источники без ETL
```
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним - **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт, шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
@@ -40,22 +51,56 @@
## Объём прогона ## Объём прогона
Параметров у конвейера нет: объём он выводит сам из `world_position` и Параметров у конвейера нет: объём он выводит сам из `world_position` и
состояния слоёв. Сессии догоняют хвост — от последнего собранного дня до состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по
последнего прожитого; заказы пересобирают дышащее окно K от позиции; карта последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта
идентичностей пересчитывается целиком; правила витрин — у идентичностей пересчитывается целиком; правила витрин — у
[слоя DM](dm.md). [слоя DM](dm.md).
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
после падения, а разгону на семь дней хватает одного прогона. после падения, а разгону на семь дней хватает одного прогона.
Окно заказов задаёт последний слепок, наблюдаемый в ODS. У каждого заказа
`snapshot_date` равен последнему слепку, в котором источник его показал.
Трансформация выбирает строки с максимальной `snapshot_date` и берёт среди них
минимальный и максимальный `order_date`. Это границы дней, состояние которых
источник ещё может изменить
([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Кроме живого окна,
трансформация закрывает пропуски: находит самый ранний день, представленный в
ODS и ещё не собранный в DDS, затем при необходимости расширяет до него левую
границу.
Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой
доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше
трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан
считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет.
Для сессий границу задаёт `world_position`: она отличает полностью прожитый
день от ещё идущего. Сами события этой границы не показывают: и посреди дня D,
и сразу после его конца их позднейший день равен D. Если собрать сессии посреди
дня, визит, начатый в 23:50, останется обрезанным: завершённый день слой
повторно не собирает. Поэтому последний прожитый день равен первому дню
событий плюс позиция минус один
([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)).
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не
позиция, а «Готовность источника».
## Готовность источника ## Готовность источника
Первый шаг `dds_transform` — дождаться, пока хвост топика `hits` дожёван: Первый шаг `dds_transform` проходит два барьера за общий срок в 300 секунд.
отставание его потребителей нулевое (`system.kafka_consumers`). Зелёный Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы
приём заказов о событиях не говорит ничего — топики разные и потребители `clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
разные. В живом режиме зазор ничтожен, но в пакетном генератор выстреливает раздела и все смещения должны существовать. Суммарное отставание должно стать
дни в топик быстрее, чем чтец их прожёвывает, и сессии собрались бы по нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
недожёванному дню. DQ-сверка такой день подсветит, но починить его до
этапа 5 нечем: профилактика дешевле ремонта, которого ещё нет. Только после этого шаг проверяет обе ноды ClickHouse через
`clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не
должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и
`last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят
один крайний срок. После Kafka срок не отсчитывается заново.
Зелёный приём заказов о событиях не говорит ничего — топики разные и
потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог
подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард.
Два барьера закрывают обе части пути до того, как сессии выберут свой объем.
## Идемпотентность ## Идемпотентность
@@ -67,14 +112,36 @@
замены: полная перезапись в `ReplacingMergeTree` — так живут карта замены: полная перезапись в `ReplacingMergeTree` — так живут карта
идентичностей и витрина `daily_traffic` ([слой DM](dm.md)). идентичностей и витрина `daily_traffic` ([слой DM](dm.md)).
## Чего конвейер о себе не знает
Трансформация собирает слой по правилу, а не сверяет его с источником. Отсюда
три вещи, которых она не заметит, и у всех трёх один адрес — контур DQ.
- **Часть последнего слепка не доехала.** Отказ вставки после коммита офсетов
уносит прочитанное с собой. Пропади так строки самого старого дня окна —
окно, выведенное из ярлыков, молча сузится, и день не пересоберётся
([`sql/dds/order_scope.sql`](../../sql/dds/order_scope.sql)).
- **Замена партиции прошла на одном шарде из двух.** День в слое есть — значит,
объём следующего прогона его не тронет, а слой останется собранным наполовину
из старого, наполовину из нового.
- **Брак внутри `items` становится нулями.** Строгий приём внутрь позиций не
смотрит, разбор по типу молчит, и цена без числа доедет до витрины выручки
нулём ([`sql/dds/order_rebuild.sql`](../../sql/dds/order_rebuild.sql)).
Общее у них одно, и оно же подсказывает лекарство: ответить может только
сверка слоя с источником по дню — счёт заказов и позднейший `updated_at` с
обеих сторон. Трансформации такую сверку не поручают намеренно: она собирает,
а проверяет другой. Так две роли остаются различимы, и ни одна не
притворяется другой.
## Не входит ## Не входит
- Партиционная переобработка — переиграть произвольный прошлый день или - Партиционная переобработка — переиграть произвольный прошлый день или
диапазон, включая форму необязательных параметров того же дага, — этап 5 диапазон, включая форму необязательных параметров того же дага.
(#7); туда же стык #111 про дату модельного мира и `logical_date`. - Инкрементальный ETL вместо пересборки отклонен (#2, wontfix). Идея отдельной
- Инкрементальный ETL вместо пересборки — техдолг #2. лабы на эту тему — #149.
- Состав витрин `dm_transform` и звена DQ — [дока слоя DM](dm.md); - Состав витрин `dm_transform` и проверок `dq_check` —
имена задач — нарезка этапа 4. [дока слоя DM](dm.md).
## Отклонённые варианты ## Отклонённые варианты
+59
View File
@@ -0,0 +1,59 @@
# Генератор кликстрима
Путеводитель показывает, как пройти генератор от корневого зерна до двух
выходов дня, не пересказывая код и спеки.
## Целевая картина одним взглядом
- **Подпоток определяется местом в дереве, а мир внутри подпотока — порядком
бросков** — [дерево зерен и порядок бросков](seeds.md).
- **Мир поднимает только когорты окна, нужные запрошенному дню** —
[ленивость мира](lazy-world.md).
- **Устройство и город принадлежат куке на всю жизнь и потому решаются
планом состава** — [паспорт куки](cookie-passport.md).
- **Выбор по весам сведен к целым броскам и арифметике интервалов** —
[целочисленная случайность](randomness.md).
- **Торговля получает готовые строки страниц и возвращает поток событий
вместе с покупками для бэкенда** — [шов торговых событий](commerce-seam.md).
- **Сутки принадлежат поясу счетчика, а `UTCEventTime` остается абсолютной
меткой** — [время модельного дня](counter-time.md).
```mermaid
flowchart LR
seed["Корневое зерно"] --> streams["Подпотоки"]
streams --> cohorts["Когорты"]
cohorts --> audience["Аудитория дня"]
audience --> visits["Визиты"]
visits --> output["Выход day.stream"]
output --> events["Поток событий"]
output --> backend["Заказы дня<br/>orders.of_day"]
```
За правой ветвью начинается отдельный набор документов —
[устройство заказов](../orders/README.md).
## Живой проход
Из каталога `generator/` одна команда собирает канонический D0 и показывает
все стадии на одном экране:
```sh
make demo
```
Когорту и аудиторию пример получает через открытый интерфейс плана, а визиты
считает группировкой выходных строк по `VisitID`. Код прохода —
[`demo.main`](../../../generator/src/clickstream_generator/demo.py).
## Родословная
Устройство генератора и основания решений записаны в [спеке этапа
2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки —
в [мастер-спеке стенда](../../specs/2026-07-30-stand-v2-realism.md).
Основания трех решений записаны в
[ADR 0015](../../adr/0015-counter-timezone.md),
[ADR 0016](../../adr/0016-cookie-passport.md) и
[ADR 0017](../../adr/0017-order-numbering.md), а спека этапа 2 закрыта.
Путеводитель появился по тикету
[#48](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/48):
он отвечает на другой вопрос — как пройти принятое устройство по коду.
@@ -0,0 +1,34 @@
# Шов торговых событий
Этот документ отвечает на вопрос, что день передает торговой части и что
получает от нее назад.
[`day.stream`](../../../generator/src/clickstream_generator/day.py) сначала
собирает выровненные по строкам ряды трафика:
- `columns` — готовые колонки просмотров страниц;
- `page` — вид страницы магазина;
- `product` — номер товара у карточки;
- `person` — человек за кукой строки.
`page` указывает, куда может сесть торговое событие, а `product` — какой товар
посетитель действительно открыл. `person` в кликстрим не выходит: он нужен
только для второй проекции покупки — заказа бэкенда.
[`commerce.weave`](../../../generator/src/clickstream_generator/commerce.py)
принимает эти ряды, вплетает `add_to_cart` и `purchase`, снова упорядочивает
поток и возвращает обновленные `columns`, `page`, `product` вместе с
[`commerce.Purchases`](../../../generator/src/clickstream_generator/commerce.py).
`Purchases` — узкая передача следующей стороне. В ней лежат номер заказа,
купленные товары и количества, клиентская выручка, промокод, момент события и
человек. Все они получены из тех же покупок, что уехали в поток, а не разобраны
назад из JSON.
[`orders.of_day`](../../../generator/src/clickstream_generator/orders.py)
принимает `Purchases`, а [`day.Day`](../../../generator/src/clickstream_generator/day.py)
отдает заказы рядом с колонками событий. Здесь поток дня расходится на две
ветви, показанные в индексе.
За этой границей начинается [устройство заказов](../orders/README.md). Судьба,
слепки и деньги бэкенда описаны там.
@@ -0,0 +1,29 @@
# Паспорт куки
Этот документ отвечает на вопрос, почему паспорт куки решает план состава, а
день только разворачивает его в события.
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) получает
случайность через
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
и вызывает [`plan._passports`](../../../generator/src/clickstream_generator/plan.py)
в ветви «состав мира». Это не подпоток отдельного дня.
`plan._passports` выбирает строки устройства и города из справочников. У
двухкуковой пары город первой куки переносится во вторую, а устройство
выбирается из другой половины профилей. Сведения о парах есть только у плана.
Если независимо вывести паспорт каждой куки из `ClientID`, один человек может
оказаться в двух городах — это будет ложью в данных.
[`plan.Cohort`](../../../generator/src/clickstream_generator/plan.py) хранит
номера устройства и города. `Cohort.visitors_on` переносит их в
[`plan.DayAudience`](../../../generator/src/clickstream_generator/plan.py), а
[`day.stream`](../../../generator/src/clickstream_generator/day.py) разворачивает
профили в колонки событий. День не бросает паспорт заново, поэтому кука
сохраняет его на всю жизнь.
Броски паспорта стоят после прежнего состава когорты. Правило такого
продолжения подпотока описано в [дереве зерен](seeds.md), а выбор
паспорта объясняет [ADR 0016](../../adr/0016-cookie-passport.md). Зачем стенду
нужна двухкуковая пара одного человека, фиксирует
[мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5.
@@ -0,0 +1,40 @@
# Время модельного дня
Этот документ отвечает на вопрос, почему дата модельных суток не обязана
совпадать с UTC-датой события.
В коде встречаются три координаты времени:
- D — номер дня на оси модельного времени;
- `EventDate` — дата D в поясе счетчика;
- `UTCEventTime` — абсолютная метка события.
Местное время гостя задает форму активности внутри суток, а границу дня задает
пояс счетчика.
[`world.ORIGIN`](../../../generator/src/clickstream_generator/world.py) — D0
календарной оси: он переводит номер D в конкретную дату. Это не механизм
часового пояса. Механизм —
[`world.COUNTER_TIMEZONE_MINUTES`](../../../generator/src/clickstream_generator/world.py),
смещение пояса счетчика от UTC.
В [`day._columns`](../../../generator/src/clickstream_generator/day.py)
`EventDate` получается как `ORIGIN + D`. Чтобы получить абсолютную полночь
тех же суток, код вычитает смещение счетчика, а затем прибавляет секунды
события внутри дня. Поэтому сутки `2026-06-01` в UTC+4 начинаются в
`2026-05-31 20:00:00Z`: у первых четырех часов
`toDate(UTCEventTime) != EventDate`.
[`day._starts`](../../../generator/src/clickstream_generator/day.py) сначала
выбирает час из городской строки весов, собранной
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py),
а затем равномерно выбирает секунду внутри часа. Строка поворачивает суточную
волну на разницу между поясом гостя и поясом счетчика. Так вечерний пик гостя
попадает в нужные часы внутри дня, но граница суток и `EventDate` остаются
счетчиковыми.
Как часовой пояс меняет представление времени в ClickHouse, описано в разделе
«Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл
полей записаны в [описании события](../../formats/clickstream-event.md).
Выбор модели времени объясняет
[ADR 0015](../../adr/0015-counter-timezone.md).
+26
View File
@@ -0,0 +1,26 @@
# Ленивость мира
Этот документ отвечает на вопрос, как любой день собирается без прожитой
истории и без глобального списка посетителей.
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) строит
одну когорту по зерну и дню ее появления. Отрицательные дни обозначают
предысторию; ее глубину ограничивает
[`world.RETURN_TAIL_DAYS`](../../../generator/src/clickstream_generator/world.py).
Запоминание уже рассчитанных когорт ускоряет соседние вызовы, но не становится
состоянием мира: очистка кеша не меняет результат.
[`plan.audience`](../../../generator/src/clickstream_generator/plan.py) для
дня D спрашивает `Cohort.visitors_on(D)` у когорт от
`D - RETURN_TAIL_DAYS` до D и соединяет полученные части. Поэтому вызов для
D0 поднимает всю предысторию в окне — 91 когорту от D−90 до D0.
Когорты предыстории дают D0 вернувшихся посетителей, но собственных событий
не порождают. Ось событий начинается в D0. Окно активности отсчитывается от
первого дня человека и остается общим для обеих его кук; за краем окна
когорта больше не может попасть в аудиторию.
Отсюда самостоятельность дня: D500 и D5 заглядывают назад на одинаковое число
когорт. Ни плану состава, ни день-функции горизонт мира не нужен.
Зачем приняты предыстория и ограниченное окно, объясняет [спека
генератора](../../specs/2026-08-01-generator.md), раздел 1.
+41
View File
@@ -0,0 +1,41 @@
# Целочисленная случайность
Этот документ отвечает на вопрос, как читать целочисленный выбор по весам и
зачем генератору свой маленький `weights.py`.
За `PCG64` и целые броски `Generator.integers` отвечает NumPy.
Генератор стенда не пользуется готовым выбором по вещественным вероятностям и
плавающими распределениями: их алгоритмы и системная математика могут
различаться между версиями и архитектурами. Зависимости закреплены, а броски
остаются целыми — так выполняется побайтовое обещание, записанное в [спеке
генератора](../../specs/2026-08-01-generator.md), раздел 2.
## Одна таблица весов
[`weights.pick`](../../../generator/src/clickstream_generator/weights.py)
сначала получает накопленные веса. Для весов `2, 3, 1` это границы
`2, 5, 6`. Затем он бросает целое число из полуинтервала `[0, 6)` и находит
первую границу справа:
- броски `0–1` выбирают вариант 0;
- броски `2–4` выбирают вариант 1;
- бросок `5` выбирает вариант 2.
Размер участка и есть вес варианта. Нормировать веса до долей единицы не
нужно.
## Своя таблица для каждой строки
[`weights.pick_row`](../../../generator/src/clickstream_generator/weights.py)
работает так же, когда у каждого броска свой ряд весов. Ряды с одинаковой
суммой укладываются друг за другом. Номер ряда смещает бросок на его участок,
единый поиск находит ячейку, а обратное смещение возвращает номер варианта.
Конкретный проход виден в
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py)
и [`day._starts`](../../../generator/src/clickstream_generator/day.py): для
каждого города суточная волна повернута в свой ряд, но сумма весов от поворота
не меняется.
При чтении вызова `pick` полезно представлять ряд целочисленных участков:
бросок выбирает вариант, в чей участок он попал.
+61
View File
@@ -0,0 +1,61 @@
# Дерево зерен и порядок бросков
Этот документ отвечает на вопрос, почему место подпотока стабильно, а порядок
бросков внутри него менять нельзя.
## Два порядка
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
и [`seeds.day_stream`](../../../generator/src/clickstream_generator/seeds.py)
находят ветвь по позиции `SeedSequence(entropy, spawn_key)`. Поэтому результат
ветви не зависит от того, какую соседнюю ветвь вычислили раньше.
Внутри найденной ветви живет один `Generator`. Каждый его вызов забирает
следующие значения. Поэтому порядок вызовов меняет результат: прежний
пятый бросок после вставки становится шестым и достается другому свойству
мира.
Дерево целиком описано в модуле
[`seeds`](../../../generator/src/clickstream_generator/seeds.py), а
[`seeds.Component`](../../../generator/src/clickstream_generator/seeds.py)
задает его нижний уровень — четыре подпотока дня:
```text
корневое зерно
├── состав мира
│ ├── ось событий → номер дня → когорта
│ └── предыстория → глубина → когорта до D0
└── дни
└── номер дня
├── TRAFFIC
├── COMMERCE
├── ORDERS
└── BEACONS — потеря и повторная отправка событий
```
Отрицательному дню нельзя отдать отрицательную позицию, поэтому предыстория
получила отдельную ветвь. Компоненты дня тоже занимают позиции, а не получают
зерна в порядке запуска.
## Что ломает вставка
Новый бросок в середине функции сдвигает все последующие значения этого
подпотока. Код продолжает работать, но тем же свойствам достается другая
случайность. Соседние подпотоки остаются прежними, а измененная часть
канонического мира проявляется в описи и побайтовом сравнении.
Какое обещание воспроизводимости это защищает, записано в
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
различать два свойства: `SeedSequence` стабилизирует адрес ветви, но порядок
бросков внутри `Generator` стабилизирует только сам код.
## Броски последними
Новое свойство продолжает существующий подпоток после прежних бросков. Такой
бросок не отнимает значения у уже рассчитанных свойств. В
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) паспорт
куки был дописан после состава и назначенных заказов, а личность человека —
после паспорта. Поэтому прежние ряды остались прежними.
Почему паспорт вообще принадлежит этой ветви, разобрано отдельно в
[документе о паспорте куки](cookie-passport.md).
+4 -2
View File
@@ -21,8 +21,10 @@ ClickHouse, два подготовленных источника данных,
| `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` | | `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` |
| `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` | | `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` |
Плагин ClickHouse 4.20.0 входит в образ Grafana. Фоновая установка плагинов Плагин ClickHouse 4.20.0 входит в образ Grafana. При сборке Grafana CLI берёт
при запуске отключена. закреплённый выпуск из официального репозитория на GitHub, а не из каталога
Grafana: прямой запрос к каталогу при проверке вернул HTTP 451. Фоновая
установка плагинов при запуске отключена.
## Кого опрашивает Prometheus ## Кого опрашивает Prometheus
+3 -11
View File
@@ -27,8 +27,8 @@
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` — не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
[судьба заказа](fate.md). [судьба заказа](fate.md).
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока — - **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
заказная и событийная стороны; броски независимы, пересечения выходят `ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
арифметикой, приоритет классов работает по-настоящему — пересечения выходят арифметикой, приоритет классов работает по-настоящему —
[классы расхождений и опоздание](fate.md). [классы расхождений и опоздание](fate.md).
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов - **Опись хранит только то, чего движение мира не меняет**: хеш байтов
каждого слепка и счётчики наблюдаемых классов — каждого слепка и счётчики наблюдаемых классов —
@@ -38,7 +38,7 @@
[мост к склейке](identity.md). [мост к склейке](identity.md).
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два - **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order` `INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order`
принимает версии заказа на `ReplacingMergeTree(updated_at)` — принимает версии заказа на `ReplacingMergeTree(snapshot_date)` —
[приём из Kafka в ODS](ingestion.md). [приём из Kafka в ODS](ingestion.md).
- **Дальше заказ живёт моделью DDS** — уже по хранилищную сторону границы, - **Дальше заказ живёт моделью DDS** — уже по хранилищную сторону границы,
в наборе слоя: [модель заказа](../dds/order.md). в наборе слоя: [модель заказа](../dds/order.md).
@@ -51,14 +51,6 @@
## Открытые решения ## Открытые решения
- **Имя подпотока событийной стороны** — вместе с её содержимым на этапе 6;
из мёртвых имён никто не бросает, переименование ничего не сдвигает (#72).
Заказная сторона уже названа — подпоток `ORDERS` ([судьба
заказа](fate.md)).
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
доли классов, стоимость доставки — калибровка при реализации; финальная
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
потребуют только числа, не устройство.
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) — - **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
остаётся в тумане карты #69; естественное место разговора — этап 4. остаётся в тумане карты #69; естественное место разговора — этап 4.
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к - **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
+30 -25
View File
@@ -16,11 +16,10 @@
и `LATECOMERS` решения не переживают — они названы по классам витрины, а и `LATECOMERS` решения не переживают — они названы по классам витрины, а
компонент называет часть мира; новые стороны занимают те же позиции. компонент называет часть мира; новые стороны занимают те же позиции.
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2; Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
событийная получит имя вместе с содержимым на этапе 6, до тех пор позиция 3 событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
занята мёртвым именем прежней нарезки. Отклонено: *один компонент на всю отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
судьбу* — правка событийной механики молча меняла бы байты слепка; событийной механики молча меняла бы байты слепка; *компонент на класс* —
*компонент на класс* — пять имён под ручки калибровки, которые крутятся пять имён под ручки калибровки, которые крутятся разом.
разом.
**Правило формы, без которого разделение не работает: броски заказной стороны **Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
@@ -56,8 +55,11 @@
часа покупки, и менти нашёл бы этот наклон первым же разрезом. часа покупки, и менти нашёл бы этот наклон первым же разрезом.
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как **Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
она делится между двумя дорогами — строки таблицы исходов. Точные числа — она делится между двумя дорогами — строки таблицы исходов. Замер на
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим. каноническом мире (28 дней, 2026-08-30, docs/architecture/testing.md, «Что
проверено»): отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12%
отменен неоплаченным; в пределах ориентира. Проверок вида «отмен от 4 до 6
процентов» не заводим.
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя **Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии версия; различает их история версий: сырьё STG и физические версии
@@ -108,12 +110,13 @@
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где один в один — посчитал наивно, удвоил выручку. Дубль случается только там,
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у где пауза до следующего визита куки длиннее таймаута и задержки дубля вместе:
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID` иначе сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон,
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита, ради которого `VisitID` в потоке лежит. Задержка дубля —
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с секунды-минуты, короче таймаута визита, поэтому `VisitID` тот же. Полночь
покупкой, по тому же правилу, что у подтверждения с торговым хвостом. режет дубль парой — просмотр вместе с покупкой, по тому же правилу, что у
подтверждения с торговым хвостом.
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* — Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
потеря перестаёт быть видна со стороны трекера. потеря перестаёт быть видна со стороны трекера.
@@ -127,18 +130,20 @@
подделывается — строка создана, когда заказ родился, — но в слепках дней подделывается — строка создана, когда заказ родился, — но в слепках дней
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
дожил: отменённый на второй день и опоздавший на третий приедет в первом же дожил: отменённый на второй день и опоздавший на третий приедет в первом же
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка — таблица своём слепке как `cancelled` — «выгрузка догоняет жизнь». Таблица задержки
весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть «D+1/D+2» состоит из трёх строк: δ = 0/1/2 с весами 90/5/5; 1 и 2 — это D+1 и D+2
мастер-спеки. Меряется она в днях снятия слепка, а не отправки: иначе сдвиг мастер-спеки. Бросок идёт последним в подпотоке `ORDERS`; затем δ назначенного
отправки удвоился бы, и обещанные D+1/D+2 стали бы D+2/D+3. Дальше таблица не планом заказа обнуляется. Меряется задержка в днях снятия слепка, а не
идёт: заказ с δ = 6 приехал бы ровно в одном слепке, и обещание «пропущенный отправки: иначе сдвиг отправки удвоился бы, и обещанные D+1/D+2 стали бы
день ничего не ломает» на нём перестало бы быть верным; при δ ≤ 2 у всякого D+2/D+3. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно в одном
заказа слепков не меньше пяти. Легенда: заказ ушёл в ручную обработку и попал слепке, и обещание «пропущенный день ничего не ломает» на нём перестало бы
в выгрузку позже. Отклонено: *сдвиг `created_at`* — подделка аудита источника: быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
день создания строки разошёлся бы с днём покупки, чьё равенство держит Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Отклонено:
синхронная модель ([откуда берётся заказ](snapshot.md)), заказ уехал бы в *сдвиг `created_at`* — подделка аудита источника: день создания строки
чужую партицию, и «выручка дня D» перестала бы отвечать покупкам дня D — разошёлся бы с днём покупки, чьё равенство держит синхронная модель
сломалась бы та самая сверка, ради которой всё строится. ([откуда берётся заказ](snapshot.md)), заказ уехал бы в чужую партицию,
и «выручка дня D» перестала бы отвечать покупкам дня D — сломалась бы та
самая сверка, ради которой всё строится.
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет **Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
+40 -27
View File
@@ -7,10 +7,10 @@
## Проблема ## Проблема
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
отдельными сообщениями и не сообщает потребителю, где слепок закончился. Прямое отдельными сообщениями и не сообщает потребителю, где слепок закончился.
чтение Kafka Engine возвращает одну порцию. Поэтому прежняя публикация через Прямое чтение Kafka Engine возвращает одну порцию без признака полноты.
`REPLACE PARTITION snapshot_date` приравнивала дату наблюдения к отсутствующей Поэтому `snapshot_date` не может служить границей публикации: `REPLACE
транспортной границе и могла заменить день неполным набором строк. PARTITION` могла бы заменить партицию дня неполным набором строк.
Одновременно типизированный ODS не должен принимать правдоподобные значения по Одновременно типизированный ODS не должен принимать правдоподобные значения по
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки. умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
@@ -19,8 +19,8 @@
- сохранить пакетный забор как контраст потоковому приёму событий; - сохранить пакетный забор как контраст потоковому приёму событий;
- один раз принять байты в STG и независимо разложить строки на годные и брак; - один раз принять байты в STG и независимо разложить строки на годные и брак;
- хранить в ODS типизированные версии заказов, не привязывая идемпотентность к - хранить в ODS типизированные версии заказов, не публикуя слепок заменой
`snapshot_date`; партиции;
- дать следующим слоям один корректный способ прочитать текущее состояние; - дать следующим слоям один корректный способ прочитать текущее состояние;
- оставить код проверки коротким и ограничить его контрактом провода. - оставить код проверки коротким и ограничить его контрактом провода.
@@ -81,6 +81,13 @@ JSON-объектом с точным набором ключей: `order_id`, `
малый контракт заказа нет причины. Граница строгости заканчивается на форме малый контракт заказа нет причины. Граница строгости заканчивается на форме
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже. провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
Контракт источника задаёт частоту: **одной дате соответствует один слепок**. Это
свойство всего слепка, поэтому предикат одной строки его не проверяет. Вторая
выгрузка того же дня даст ничью по `snapshot_date`, и выбор строки потеряет
однозначность. Модельный источник выполняет условие; в настоящей интеграции
его фиксируют в договоре с источником
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
Класс брака выбирается первым совпадением: Класс брака выбирается первым совпадением:
1. `not_an_object`; 1. `not_an_object`;
@@ -94,31 +101,34 @@ JSON-объектом с точным набором ключей: `order_id`, `
## Роль ODS ## Роль ODS
`ods.order_rep` хранит физически принятые версии в `ods.order_rep` хранит физически принятые версии в
`ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день `ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
неизменного `created_at`. `ods.order_dist` шардирует по день неизменного `created_at`. `ods.order_dist` шардирует по
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и `cityHash64(order_id)`: только так все версии заказа попадают на один шард и
`FINAL` даёт корректный результат через распределённую таблицу. `FINAL` даёт корректный результат через распределённую таблицу.
Четыре координаты отвечают на разные вопросы: Четыре координаты отвечают на разные вопросы:
- `updated_at` — какая бизнес-версия заказа новее;
- `snapshot_date` — в слепке какого модельного дня источник показал строку; - `snapshot_date` — в слепке какого модельного дня источник показал строку;
- `updated_at` — когда строку последний раз меняли в источнике;
- `_load_id` — какой запуск Airflow принял строку; - `_load_id` — какой запуск Airflow принял строку;
- `_load_ts` — когда строка приехала в хранилище. - `_load_ts` — когда строка приехала в хранилище.
Версию выбирает только `updated_at`. Заказ, не менявшийся несколько дней, Версию выбирает `snapshot_date`: выживает строка с максимальной датой слепка
приезжает в нескольких слепках одинаковыми деловыми полями, и какая из этих ([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому
строк переживёт схлопывание — не обещано. Поэтому `snapshot_date` выжившей `snapshot_date` показывает последнее наблюдение заказа. По строкам последней
строки — один из дней, когда источник показывал эту версию, не обязательно даты слепка DDS определяет окно пересборки. `_load_id` и `_load_ts` принадлежат
последний; то же с `_load_id` и `_load_ts`. Вопрос «когда заказ наблюдался в той физической строке, которую сохранило слияние, и описывают её доставку.
последний раз» задаётся сырью STG, пока то живо, — а не одной строке ODS.
Сама схема версий стоит на допущении об источнике: уточнение заказа поднимает Схема версий стоит на дисциплине источника: слепок с поздней датой не
`updated_at`. Генератор моделирует источник с триггерной меткой и допущение показывает состояния старше. Модельный мир её держит — состояние читается на
выполняет (замер — «Что проверено»); источник, умеющий уточнить строку тихо — границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
ручным `UPDATE` мимо приложения, миграцией, — ломает его, и тогда версии по собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
метке времени мало: нужна сверка самих полей, в терминах dbt snapshots — при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
стратегия `check` вместо `timestamp`. что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
больше не показывает, и там помогает только сверка самих полей, в терминах
dbt snapshots — стратегия `check` вместо `timestamp`.
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории. диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
@@ -201,12 +211,15 @@ JSON-объектом с точным набором ключей: `order_id`, `
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу `system.query_log` на первой ноде осталось ровно по одному завершённому запросу
пользователя `etl` для забора в STG, записи годных строк и записи брака. пользователя `etl` для забора в STG, записи годных строк и записи брака.
Допущение и произвол ярлыков замерены 22 августа 2026 года при исполнении Дисциплина источника замерена 22 августа 2026 года при исполнении #137.
#137: во всех 3034 парах строк с равным `updated_at` деловые поля совпали Во всех 3034 парах строк с равным `updated_at` деловые поля совпали
побайтово, по слепкам метка ни разу не убывала, а `ods.order_v` сошёлся побайтово, а по слепкам метка ни разу не убывала.
построчно с эталоном `argMax` по `updated_at`, посчитанным прямо из сырья.
Произвол ярлыков виден въявь: у 86 заказов стартового мира выжила строка Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
серединного слепка — не первого и не последнего. исполнении #165. После двух слепков физическая таблица содержала 3356 версий
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним
слепком заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из
сырья. В обеих сверках расхождений нет.
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны, #93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
+12 -9
View File
@@ -18,9 +18,10 @@
в трекере — не отдельная порода, а класс B, и делает его событийная сторона в трекере — не отдельная порода, а класс B, и делает его событийная сторона
выбрасыванием события после присвоения номера выбрасыванием события после присвоения номера
([классы расхождений](fate.md)); ([классы расхождений](fate.md));
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`, - обе проекции используют один номер заказа: `order_id` = клиентский
читаемый номер «день и порядковый номер покупки» ([спека `purchaseID`;
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все читаемый номер «день и порядковый номер покупки» описан в
[ADR 0017](../../adr/0017-order-numbering.md); нумеруются все
покупки, дошедшие до потока дня, — до всяких потерь; покупки, дошедшие до потока дня, — до всяких потерь;
- скидка заказа выводится из промокода события по таблице «код → скидка» — - скидка заказа выводится из промокода события по таблице «код → скидка» —
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9). числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
@@ -59,12 +60,14 @@
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
топиком `hits`* — убивает два режима приёма. топиком `hits`* — убивает два режима приёма.
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и **Сборка окна.** Слепок дня D берет заказы, рожденные в дни D−6…D, а несет из
собирается переигровкой этих семи дней: заказы дня — производная всей воронки них те, что уже попали в выгрузку: механика опоздания живет в
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у [судьбе заказа](fate.md). Окно собирается переигровкой этих семи дней: заказы
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние дня — производная всей воронки дня, дешёвого пути к ним нет. Цена — семь
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок; проигрышей дня (~14 с) на слепок; у начала оси окно усекается само. Отклонено:
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве. *кэш заказов на томе* — состояние между прогонами; *окно держит хранилище* —
топик перестаёт нести слепок; *K = 1* — это страховочный срез 1 мастер-спеки,
он в резерве.
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим **Отправка.** Слепок **снимается на границе суток, а отправляется следующим
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
+100 -21
View File
@@ -15,7 +15,10 @@ keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/d
Тот же этап принёс первый справочник — словарь товаров из общего с генератором Тот же этап принёс первый справочник — словарь товаров из общего с генератором
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат
публичным договором события `dds.event_v` над точным состоянием ODS. публичным договором события `dds.event_v` над точным состоянием ODS, а следом
получил две физические модели слоя — заказ и сессию: их собирает заменой
партиций даг `dds_transform`. Сессия среди них особая: в потоке такой сущности
нет, стенд режет её из событий сам.
Дальше по тексту устройство описано так, как оно проектируется; построенное от Дальше по тексту устройство описано так, как оно проектируется; построенное от
заложенного отличает карта таблиц в конце. заложенного отличает карта таблиц в конце.
@@ -129,6 +132,14 @@ FROM источник_dist`. Заводское значение `parallel_distr
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким
объектом, а не переносить на ODS заранее. объектом, а не переносить на ODS заранее.
Первым такой донор понадобился заказу DDS, и правило на нём видно целиком:
`dds.order_stage_rep` объявлен через `AS dds.order_rep` и шардируется тем же
`cityHash64(order_id)`. Совпадение структуры требует сама операция, а совпадение
ключа — устройство замены: она идёт по локальным таблицам, шард за шардом, и
строка, попавшая в доноре не на свой шард, при подмене пропала бы с одного шарда
и задвоилась на другом. Доводы целиком — в
[`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql).
## Служебные колонки ## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок: Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
@@ -172,8 +183,8 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает `ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
повтор доставки. У `ods.order` версию задаёт `updated_at` источника; повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
`_load_ts` только показывает, когда конкретная строка приехала. источника; `_load_ts` только показывает, когда конкретная строка приехала.
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся. `created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
@@ -210,10 +221,17 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том `dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
числе snake_case-имена и расшифровку кодов. числе snake_case-имена и расшифровку кодов.
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
исключение: источник объявляет `Array(Float64)`, а материализованное
представление ODS разбирает числа сырого JSON сразу в
`Array(Decimal(18, 2))`. DDS наследует этот тип без нового преобразования.
Прямой разбор подтвержден документацией ClickHouse через Context7 и запросом
к ClickHouse 26.3; сравнение с сырьем подтвердило: копейки всех покупок целы.
## Версии заказов ## Версии заказов
`ods.order_rep` хранит принятые версии в `ods.order_rep` хранит принятые версии в
`ReplacingMergeTree(updated_at)`: ключ сортировки — `order_id`, партиция — `ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
`toDate(created_at)`. `ods.order_dist` шардирует по `toDate(created_at)`. `ods.order_dist` шардирует по
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли `cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL` схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
@@ -223,11 +241,13 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
версии, которые ещё не убрали фоновые слияния, и не является архивом истории. версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
`ods.order_v` служит поверхностью точного текущего состояния для следующих `ods.order_v` служит поверхностью точного текущего состояния для следующих
слоёв. Представление сохраняет язык источника и не решает, какой станет модель слоёв. Представление сохраняет язык источника и не решает, какой станет модель
DDS. `snapshot_date` в нём остаётся датой наблюдения строки, а не ключом DDS. `snapshot_date` в нём одновременно дата наблюдения и колонка версии. Она
публикации. Полное решение — в не доказывает полноту принятой порции, поэтому ODS не публикует партиции по
[ADR 0010](../adr/0010-order-versions-in-ods.md) и дате слепка. Модель версионного ODS записана в
[спецификации приёма заказов](orders/ingestion.md). Какой моделью заказ [ADR 0010](../adr/0010-order-versions-in-ods.md), выбор колонки версии описан в
живёт дальше — [модель заказа в DDS](dds/order.md). [ADR 0014](../adr/0014-order-version-snapshot-date.md), пакетный приём описан в
[спецификации приёма заказов](orders/ingestion.md). Контракт следующего слоя
задан в [модели заказа в DDS](dds/order.md).
## Часовые пояса ## Часовые пояса
@@ -276,7 +296,7 @@ UTC+4), и пересчёт идёт один раз при наполнении
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана **День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
генератором и лежит колонкой, так что суточные срезы группируются по ней и генератором и лежит колонкой, так что суточные срезы группируются по ней и
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9. расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md).
## Путь реплицированных таблиц в keeper ## Путь реплицированных таблиц в keeper
@@ -470,9 +490,9 @@ kafka_offset)`: смотрят такую таблицу от класса, а
административная операция, роль `etl` права на неё не получает. административная операция, роль `etl` права на неё не получает.
Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт
`Decimal(18, 2)`, `productPrice` события — целые рубли. Разрыв намеренный, на `Decimal(18, 2)`, `productPrice` события — целые рубли. Поэтому точную сумму
нём стоит урок про `Float64` ([описание заказа берут из `purchaseRevenue`, а не складывают округленные цены товаров
выгрузки](../formats/clickstream-event.md)). ([описание выгрузки](../formats/clickstream-event.md)).
Устройство обоих объектов — почему такой движок, такая форма пути, такой Устройство обоих объектов — почему такой движок, такая форма пути, такой
пользователь и такое окно — расписано в пользователь и такое окно — расписано в
@@ -497,8 +517,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
### DDL ### DDL
Файлы лежат в `sql/ddl/` и применяются по порядку имён: зависимости создаются Файлы лежат в `sql/ddl/` и применяются по порядку имён: источник и цель
раньше потребителей. создаются до представления или матвью, которые их используют.
| Файл | Что в нём | | Файл | Что в нём |
|---|---| |---|---|
@@ -508,7 +528,10 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок | | `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS | | `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
| `40-stg-views.sql` | матвью приёма: чтец в сырьё | | `40-stg-views.sql` | матвью приёма: чтец в сырьё |
| `50-dds-views.sql` | публичный договор события на языке модели DDS | | `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
| `50-dds-views.sql` | публичные договоры сущностей DDS |
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
| `60-dm-views.sql` | публичные договоры витрин DM |
Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
правила порядка слоёв к ней не применяются. правила порядка слоёв к ней не применяются.
@@ -567,6 +590,25 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
| `ods` | `ods.order_v` | текущая версия заказа на языке источника | | `ods` | `ods.order_v` | текущая версия заказа на языке источника |
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | | `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
| `dds` | `dds.event_v` | событие на языке бизнес-модели без физической копии | | `dds` | `dds.event_v` | событие на языке бизнес-модели без физической копии |
| `dds` | `dds.order_rep` / `_dist` | заказ в текущем состоянии, партиция — день заказа |
| `dds` | `dds.order_stage_rep` / `_dist` | двойник заказа: в нём день собирается перед заменой партиции |
| `dds` | `dds.order_v` | публичный договор заказа |
| `dds` | `dds.session_rep` / `_dist` | визит, нарезанный слоем; партиция — день сессии |
| `dds` | `dds.session_stage_rep` / `_dist` | двойник сессии: в нём день собирается перед заменой партиции |
| `dds` | `dds.session_v` | публичный договор сессии |
| `dds` | `dds.identity_map_rep` / `_dist` | известная пара куки с пользователем; таблицы полного пересчёта |
| `dds` | `dds.identity_map_v` | точный публичный договор карты идентичностей с `FINAL` |
| `dm` | `dm.revenue_daily_rep` / `_dist` | дневная выручка по категориям |
| `dm` | `dm.revenue_daily_stage_rep` / `_dist` | таблица-двойник, донор дневных партиций выручки |
| `dm` | `dm.revenue_daily_v` | публичный договор дневной выручки |
| `dm` | `dm.purchase_vs_orders_rep` / `_dist` | сверка покупок с заказами, партиция — день заказа |
| `dm` | `dm.purchase_vs_orders_stage_rep` / `_dist` | донор дневных партиций сверки |
| `dm` | `dm.purchase_vs_orders_v` | публичный договор сверки с `NULL` для отсутствующей стороны |
| `dm` | `dm.daily_traffic_rep` / `_dist` | версии дневных счетчиков трафика |
| `dm` | `dm.daily_traffic_v` | точные текущие счетчики трафика с `FINAL` |
| `dm` | `dm.dq_summary_rep` / `_dist` | текущее качество дня по атомарным проверкам |
| `dm` | `dm.dq_summary_stage_rep` / `_dist` | общий донор дневных партиций DQ-сводки |
| `dm` | `dm.dq_summary_v` | публичная сводка качества дней |
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором | | `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
| `dic` | `dic.products` | словарь товаров поверх подложки | | `dic` | `dic.products` | словарь товаров поверх подложки |
@@ -574,9 +616,10 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
`INSERT SELECT` одной задачи `parse_batch` из файла `INSERT SELECT` одной задачи `parse_batch` из файла
`sql/ods/order_parse.sql`. `sql/ods/order_parse.sql`.
Слой DDS начат представлением `dds.event_v`; остальные его сущности и слой DM В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
появятся в следующих тикетах этапа. Их состав задан разделом 7 мастер-спеки и остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
переносится сюда по мере постройки. карта пишет полный набор пар в версионную таблицу. В DM выручка и сверка
заменяют партиции изменяемых дней, а трафик пишет полную историю новой версией.
## Что проверено ## Что проверено
@@ -649,8 +692,8 @@ DDL-словарь с файловым источником внутри `user_f
порции из пяти строк (две версии одного заказа плюс по одной строке каждого порции из пяти строк (две версии одного заказа плюс по одной строке каждого
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL` ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
через распределённую таблицу вернул одну строку — ту, у которой `updated_at` через распределённую таблицу вернул одну строку — ту, что несла позднейшую
позже, — тогда как физический счёт показывал две. Повтор задачи с тем же версию, — тогда как физический счёт показывал две. Повтор задачи с тем же
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок `_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
записала тот же брак второй раз, как и обещано спецификацией приёма. записала тот же брак второй раз, как и обещано спецификацией приёма.
@@ -788,5 +831,41 @@ Kafka с пустым значением (ноль байт) и запись-н
таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет: таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет:
служебные колонки мы заполняем выражением при любом ответе. служебные колонки мы заполняем выражением при любом ответе.
**Проверка замены партиций 26 августа 2026 года (#151).** Context7 подтвердил
договор `REPLACE PARTITION`: `ON CLUSTER` в синтаксисе есть, а источник и цель
обязаны совпадать структурой, ключом партиции, ключом сортировки, первичным
ключом и политикой хранения. На закреплённом ClickHouse 26.3 замена по локальным
таблицам с `ON CLUSTER` прошла на обеих нодах, а сборка стартового мира в
двойник разложила 1662 заказа строго по ключу: шард 1 получил только чётные
`cityHash64(order_id)`, шард 2 — только нечётные.
**Проверка карты идентичностей 26 августа 2026 года (#153).** Context7
подтвердил версию в `ReplacingMergeTree` и точное чтение через `FINAL`. Два
полных прогона оставили 3180 физических строк и 1590 пар в `_v`; все пары
подтверждены покупкой и заказом, а строк на чужом шарде нет.
Тем же днём измерены ещё три поведения ClickHouse.
- **Параметр в `ALTER … PARTITION` не подставляется.** Запрос с `{day:Date}` в
выражении партиции отвечает кодом 62, синтаксической ошибкой; литерал
проходит. Отсюда единственное место в репозитории, где значение попадает в
запрос подстановкой Jinja, а не параметром ClickHouse
([`sql/dds/order_replace.sql`](../../sql/dds/order_replace.sql)).
- **`TRUNCATE` — отдельное право, в `ALTER` оно не входит.** Роль `etl`, у
которой есть `ALTER ON dds.*`, получает на `TRUNCATE` код 497, а
`ALTER TABLE … DROP PARTITION ALL` на той же таблице проходит. Двойник
поэтому чистится снятием партиций: у операции нашлась форма, на которую право
уже выдано, и расширять доступ не понадобилось.
- **`JSONExtract` читает `Decimal` прямо из строки JSON.** Цена на проводе —
строка с двумя знаками (`"1289.99"`), и разбор в
`Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))` даёт число без
промежуточного `toDecimal64`. Проверено на живом слепке; честность разбора
сторожит инвариант бэкенда — сумма «цена × количество» совпала с `items_total`
у всех 1662 заказов.
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
ClickHouse 26.3; все суммы после ODS совпали с сырьем до копейки.
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие **Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
матвью разбора, закрыты опытами при исполнении #43 и переехали выше. матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
+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).
+132 -10
View File
@@ -34,20 +34,21 @@
спрашивают его отдельно. спрашивают его отдельно.
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено». Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
`make config-test`, `make smoke` и `make check-services` перемерены 21 августа `make smoke` и `make check-services` перемерены 28 августа 2026 года,
2026 года, `make check-clickhouse` — 19 августа, корневой `make lint` — `make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
9 августа, `make test` — 7 августа; остальные стоят с замера 6 августа. корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с
замера 6 августа.
| Цель | Откуда | Что утверждает | Стенд | Цена | | Цель | Откуда | Что утверждает | Стенд | Цена |
|---|---|---|---|---| |---|---|---|---|---|
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с | | `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с | | `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 17 с | | `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с | | `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 93 с | | `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с | | `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с | | `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с | | `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за **Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
разными дверями охватывают разное: корневая не видит ни одного файла разными дверями охватывают разное: корневая не видит ни одного файла
@@ -120,10 +121,11 @@ ClickHouse отвечает сразу.
из каталога `generator/`: стенд им не нужен, а `make test` из них самая из каталога `generator/`: стенд им не нужен, а `make test` из них самая
дорогая. Правки дагов и Superset — корневой `make lint`. дорогая. Правки дагов и Superset — корневой `make lint`.
Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
получается мир, его надо получить. Место выбрано по той же оси «кого узнать, тот ли получается мир, его надо получить. Место выбрано по той же
спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не
нужен, — и
краснеет проверка там, где надо: сразу после правки генератора. Не будь её, краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
поломкой хранилища, а не забытой пересборкой. поломкой хранилища, а не забытой пересборкой.
@@ -237,6 +239,126 @@ 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 и обе витрины. После пересборки прошли
`make smoke` 21/21 и `make check-clickhouse` 10/10. Перед стендовыми сценариями
прошли `make config-test` и `make lint`.
Два прогона `world_next_day` не изменили слои; один ручной `etl_pipeline` без
параметров догнал оба дня. Полный прогон `world_live_day` занял 27 м 2 с и
завершился только после успешных DDS и DM.
Временная синтаксическая поломка `sql/dm/daily_traffic_rebuild.sql` дала
красную цепочку: DDS завершился успешно, а `daily_traffic`, DM, `etl_pipeline`
и `world_live_day` покраснели. После возврата исправного SQL ручной
`etl_pipeline` без параметров завершился успешно на позиции `12` и восстановил
слои.
Для проверки пакетного барьера `stg.hits_raw_mv` временно отсоединили командой
`DETACH` на обеих нодах. После `world_next_day` отставание Kafka составило
46 367 (19 128 + 27 239). Во время ожидания матвью вернули командой `ATTACH`.
Журнал готовности записал повторные опросы с отставанием 46 367, затем 27 239
и 0; после этого очередь `ods.event_dist` содержала 0 файлов на обеих нодах,
и ETL завершился успешно. Проверка `EXISTS` снова дала `1` на обеих нодах,
позиция равна `13`.
После пересборки `make check-services` дважды дал 5/1: только импорт Superset
не уложился в существующий тайм-аут 30 с. Диагностический пробник импорта с
ожиданием 90 с прошел 3/0. Финальный штатный повтор прошел 7/7 за 129 с.
**Перезамер 21 августа 2026 года при исполнении #124.** Образ Airflow собран с **Перезамер 21 августа 2026 года при исполнении #124.** Образ Airflow собран с
`apache-airflow-providers-clickhousedb==1.0.0`, `apache-airflow-providers-clickhousedb==1.0.0`,
`apache-airflow-providers-common-sql==2.0.1` и `apache-airflow-providers-common-sql==2.0.1` и
+7 -5
View File
@@ -11,10 +11,12 @@
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки. собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и Имена и типы колонок описывают сторону источника. Обычно ODS принимает их без
нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное изменений. Исключение — `purchaseRevenue`: источник объявляет Float64, а ODS
имя». Это имя источника, приведённое к snake_case, а не имя атрибута в хранит деньги в Decimal. Точный локальный тип назван в документации
модели данных: слой DDS складывает свою модель и называет атрибуты по ней. хранилища. Имя в нашем стиле ждет в столбце «Нормализованное имя». Это имя
источника, приведенное к snake_case, а не имя атрибута в модели данных: слой
DDS складывает свою модель и называет атрибуты по ней.
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора; Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
контрактом. контрактом.
@@ -90,7 +92,7 @@
| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий | | № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 37 | `purchaseID` | `Array(String)` | `object` | `purchase_id` | номер заказа; у события purchase — один элемент | | 37 | `purchaseID` | `Array(String)` | `object` | `purchase_id` | номер заказа; у события purchase — один элемент |
| 38 | `purchaseRevenue` | `Array(Float64)` | `float64` | `purchase_revenue` | выручка заказа глазами клиента; Float64, как у Метрики — на этом держится урок о расхождениях с бэкендом | | 38 | `purchaseRevenue` | `Array(Float64)` | `float64` | `purchase_revenue` | выручка заказа глазами клиента; источник объявляет Float64, ODS хранит точный Decimal |
| 39 | `purchaseCurrency` | `Array(String)` | `object` | `purchase_currency` | валюта заказа | | 39 | `purchaseCurrency` | `Array(String)` | `object` | `purchase_currency` | валюта заказа |
| 40 | `purchaseCoupon` | `Array(String)` | `object` | `purchase_coupon` | купон заказа, если был применён | | 40 | `purchaseCoupon` | `Array(String)` | `object` | `purchase_coupon` | купон заказа, если был применён |
| 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события | | 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события |
+109
View File
@@ -0,0 +1,109 @@
# Учебные задания: витрины слоя DM и дашборд в Superset
Пять домашних заданий — про витрины слоя DM. Стенд привозит слой
наполовину: витрины трех сюжетов дашборда и контроль качества построены,
остальные витрины строит менти. Каждое из них — самостоятельное ТЗ: что за
витрина, какое у нее зерно, из чего она собирается и как проверить, что
собралась верно.
Шестое задание — необязательная лаба в Superset. В ней менти собирает свой
дашборд поверх пяти витрин.
Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на
вопросы «почему так», задание отвечает на вопрос «что сделать».
## Порядок
Задания идут по нарастанию: каждое следующее опирается на то, что уже
усвоено.
| № | Результат | Чему учит |
|---|---|---|
| 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных |
| 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер |
| 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит |
| 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни |
| 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение |
| 6 | [Свой дашборд в Superset](superset-dashboard.md) — необязательная лаба | свои объекты BI живут отдельно от стендовых |
## Общие правила
Они одни для пяти заданий по витринам, поэтому записаны здесь, а не
повторяются в каждом ТЗ.
**Читать — через `_v`.** Публичный договор сущности DDS — представление с
суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только
его; физические `_rep` и `_dist` служат загрузке и обслуживанию. Соседнюю
домашку витрина источником тоже не берет: витрина слоя DM читает договор
DDS, и задания остаются независимыми. Исключение одно и оно оговорено в
задании 4: у таблиц брака публичного договора нет.
**Писать — только в `_dist`.** При записи в локальную таблицу раскладку
определяет то, какая нода случайно выполняла код, а не свойство данных.
Правило и его следствия — [дока хранилища](../architecture/storage.md),
«Раскладка по шардам».
**Форма — по зерну.** Витрина, меняющая зерно источника, материализуется:
`dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / `_stage_dist` и
публичное `dm.<имя>_v` с явным списком колонок. Витрина, сохраняющая зерно,
остается обычным представлением. Довод — [дока DM](../architecture/dm.md),
«Форма: по зерну, не по цене запроса».
**Ключ шардирования — твое решение.** Ни одно ТЗ ключа не называет.
Правило выбора и требование к донору — [дока
хранилища](../architecture/storage.md), «Раскладка по шардам»: донор
объявляется через `AS <цель>_rep` и шардируется тем же ключом, что цель,
иначе замена партиции потеряет строку на одном шарде и задвоит на другом.
Названный ключ надо уметь защитить: почему он не дает перекоса и почему
более сложный тут ничего не добавляет. Образец такой защиты —
`dm.revenue_daily_dist` в [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
**Где лежит код.** DDL домашек — свой файл `sql/ddl/70-labs.sql`: он
применяется после стендовых по порядку имени, и твои объекты не мешаются
со стендовыми. У агрегатов рядом со стендовыми ложатся еще три запроса —
`sql/dm/<витрина>_scope.sql` спрашивает у хранилища, какие дни пересобирает
запуск, `_rebuild.sql` собирает их в донор, `_replace.sql` заменяет
партицию, — и задача в
[`dags/dm_transform.py`](../../dags/dm_transform.py). У разминочного
представления нет ничего из этого: весь его код — одно `CREATE VIEW`.
**Образец на все случаи** — витрина `revenue_daily`: у нее есть все пять
объектов, донор, сборка, замена дневных партиций и задача в конвейере.
Списывать с нее устройство можно и нужно; списывать состав колонок — нет,
он у каждой витрины свой.
## Самопроверка
Даг `homework_check` запускается рукой, расписания у него нет. Задача
витрины красная, пока витрины нет или счет не сходится, и зеленая, когда
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет:
это инструмент лабы, а не история качества стенда.
Проверка пяти заданий по витринам устроена одинаково и золотых констант не
содержит: рядом с витриной независимым запросом собирается тот же ответ —
прямо из источника витрины, минуя ее саму. У четырех заданий источник эталона
тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это
таблицы брака ODS, потому что в DDS у брака представительства нет. Обе стороны
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число строк,
где стороны разошлись или одной стороны нет. Ноль — зеленый.
Полное соединение здесь обязательно: при обычном пропавший день или
пропавший ключ просто не попал бы в результат и остался бы незамечен.
Раздел «Как проверить себя» каждого ТЗ по витрине называет деловой ключ и
сравниваемые величины. Тот же прием в стендовом исполнении — [даг
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
**Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и
имена называют витрину и роль сверки. Проверка на инвариантах неизбежно
близка к ответу: чтобы сверить витрину, надо ее пересчитать. Прятать эти
файлы никто не станет — но открыв их до того, как задание сделано, ты
обменяешь урок на готовый SQL.
Смотреть туда стоит после своей сборки: сравнить два решения полезно,
списать одно — нет.
## Уборка
Черновики и опыты за собой сносить: правило проверок стенда —
[дока тестирования](../architecture/testing.md). Снесенная витрина обязана
снова покраснить свою задачу в `homework_check` — если не покраснила,
проверка ничего и не проверяла.
+118
View File
@@ -0,0 +1,118 @@
# Задание 4: `dm.dq_errors_daily`
**Чему учит:** у брака свой день и свой срок жизни. Витрина качества живет
по времени загрузки, а не по модельному дню, и переживает собственный
источник — поэтому «пересобрать все» здесь означает «стереть историю».
## Роль витрины
Сводка приемки: сколько строк за день не прошли разбор и по какой причине.
Читают ее, когда качество потока просело: сначала смотрят, вырос ли класс
брака, потом идут за сырым текстом в саму таблицу ошибок.
Витрина отвечает на вопрос «сколько и чего», а не «что именно сломалось».
Разбирательство с конкретной строкой начинается в `ods.event_errors_dist` и
`ods.order_errors_dist`, где рядом с классом лежат сырой текст и координаты
доставки.
## Источник: единственное исключение из правила `_v`
Общее правило домашек — читать через публичный договор `_v`. У таблиц брака
такого договора нет, и заводить его ради этой витрины не надо: брак — не
сущность бизнес-модели, а диагностика слоя приема. Читать нужно
распределенные таблицы напрямую:
- `ods.event_errors_dist` — брак разбора событий;
- `ods.order_errors_dist` — брак разбора слепка заказов.
Именно `_dist`, а не `_rep`: локальная таблица вернет строки одного шарда, и
витрина тихо покажет половину брака. Ошибки при этом не будет — ошибка
появится позже, когда по витрине примут решение.
## Зерно и форма
Зерно — **день загрузки × источник × класс брака**. Зерно источника —
строка брака, значит зерно сменилось и витрина материализуется:
`dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
публичное `dm.dq_errors_daily_v`. Ключ партиции — день загрузки.
День здесь — `toDate(_load_ts)`, то есть настоящее время, когда строка
приехала в хранилище, а не модельный день мира. Модельного дня у брака нет
и быть не может: он живет в содержимом, а содержимое как раз и не
разобралось.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `load_date` | `Date` | день загрузки, в который приехал брак |
| `source` | `LowCardinality(String)` | `event` или `order` |
| `error_class` | `LowCardinality(String)` | класс брака из таблицы ошибок |
| `error_rows` | `UInt64` | число забракованных строк |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
Две похожие колонки рядом различаются намеренно: `load_date` — про день,
когда приехал брак, `_load_ts` — про запуск, который собрал витрину. Первая
деловая, вторая служебная, и совпадать они не обязаны.
Классы у двух источников свои и не пересекаются по смыслу, поэтому колонка
`source` в зерне обязательна: без нее одинаковое имя класса у событий и у
заказов слилось бы в одну строку.
## Ритм пересборки: витрина переживает источник
Таблицы брака живут месяц (`TTL`), сырье — трое суток. Витрина живет без
срока: сводка «сколько брака было в марте» нужна и после того, как сами
строки марта удалены.
Отсюда правило прогона: пересобираются **только те дни загрузки, которые
еще есть в источнике**. Спроси у источника список живых дней и замени
партиции ровно этих дней. Соблазн «собрать все заново в донор и подменить
таблицу целиком» здесь стоит дороже обычного: истекшие по `TTL` дни исчезли
бы из витрины навсегда, а вместе с ними — единственная оставшаяся память о
них.
Текущий день загрузки не замерзает до конца суток: поток событий идет, и
брак в него еще добавится. Это нормально, день просто пересоберется на
следующем прогоне.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Витрина крошечная: классов брака у событий и заказов вместе меньше
десятка. Учти это в защите ключа — иногда правильный ответ звучит как
«перекос здесь ничего не стоит, и вот почему».
## Как проверить себя
Деловой ключ — пара `source` и `error_class` внутри `load_date`. Область
проверки — дни загрузки, которые еще живы хотя бы в одной таблице ошибок:
день за границей `TTL` сверять не с чем, и его отсутствие в источнике не
ошибка.
Собери прямой счет по обеим таблицам ошибок, соедини с
`dm.dq_errors_daily_v` полным внешним соединением по ключу и посчитай
строки, где ключа нет с одной из сторон или где расходится `error_rows`.
Ноль — зеленый.
Отдельно: сумма `error_rows` по живым дням равна общему числу строк в
`ods.event_errors_dist` плюс `ods.order_errors_dist`. Если эта сумма вышла
примерно вдвое меньше — витрина почти наверняка собрана с `_rep` вместо
`_dist` и видит один шард из двух.
**Пустая витрина — верный ответ.** На чистом стенде брака нет, обе стороны
пусты, расхождений ноль, задача зеленая. Проверка, которая требует хотя бы
одной строки, на здоровом стенде краснела бы всегда и приучала бы себя
игнорировать.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] источники — `_dist`-таблицы обоих видов брака;
- [ ] прогон заменяет партиции только живых дней источника и не трогает
истекшие;
- [ ] задача `dq_errors_daily` в `homework_check` зеленая — в том числе на
чистом стенде, где брака нет;
- [ ] задача витрины добавлена в `dm_transform`.
+114
View File
@@ -0,0 +1,114 @@
# Задание 1: `dm.events_enriched_v`
**Чему учит:** слой — это контракт, а не копия данных. Витрина, которая не
меняет зерно источника, остается представлением, и копии событий ради
других имен колонок не заводит ни один боевой склад.
Это разминка. Строк она не хранит, в конвейер не встает, ключа шардирования
у нее нет. Задача одна: собрать плоское событие для BI и не поддаться
соблазну разложить его на диск.
## Роль витрины
Договор события в DDS широкий — 44 колонки, почти весь формат выгрузки:
часть из них технические координаты источника, часть — параллельные массивы
товаров. Аналитику в Superset нужен плоский набор деловых полей и пара
производных, которые иначе каждый пишет заново в каждом запросе.
Витрина отвечает на вопрос «что вообще происходило»: с нее начинают, когда
надо посмотреть на события глазами человека, а не разбирать широкую строку
источника.
Источником для витрин следующих заданий она не служит. Витрина слоя DM
читает публичный договор DDS — так устроены и стендовые витрины, и потому
задания независимы: соседнюю домашку можно делать первой, а можно не делать
вовсе.
## Зерно и форма
Зерно — **событие**: одна строка витрины равна одной строке `dds.event_v`.
Зерно источника сохранено, значит форма — обычное представление
`CREATE VIEW dm.events_enriched_v`, без физической таблицы, без донора и
без задачи в `dm_transform`. Пересобирать нечего.
Источник — `dds.event_v`, и только он.
## Состав колонок
Явный список обязателен: договор не должен расширяться сам, когда в
источник добавят колонку.
**Время и посетитель:** `event_date`, `event_time`, `event_hour`,
`client_id`, `event_id`.
**Действие:** `event_type` (`pageview`, `add_to_cart`, `purchase`), `url`,
`page_path`, `referer`, `referer_host`, `title`.
**Атрибуция:** `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`,
`utm_term`.
**Паспорт визита:** `device_category`, `browser`, `operating_system`,
`region_country`, `region_city`.
**Торговый блок, массивами как в источнике:** `product_id`, `product_name`,
`product_category`, `product_price`, `product_quantity`,
`product_event_type`, `purchase_id`, `purchase_revenue`, `purchase_coupon`.
Три колонки в источнике не лежат и считаются здесь:
| Колонка | Как считается | Зачем |
|---|---|---|
| `event_hour` | `toHour(event_time)` | суточный профиль спрашивают в каждом отчете |
| `page_path` | путь `url` без домена и параметров | группировка по странице без разбора строки в каждом запросе |
| `referer_host` | домен `referer` | «откуда пришли» одним значением, а не полным адресом |
Служебных `_load_id` и `_load_ts` у витрины нет, и это не забывчивость. Их
нет и у `dds.event_v`: событие в DDS не грузят, а читают из ODS, поэтому
координат собственной загрузки у него не бывает. Служебная колонка называет
запуск, который положил строку на диск, — здесь на диск никто ничего не
кладет.
## Ловушка задания
Массивы `product_*` тянет развернуть через `ARRAY JOIN` — так плоская
таблица становится по-настоящему плоской. Разворачивать нельзя, и вреда
здесь два.
Очевидный: у события с тремя позициями получилось бы три строки, зерно
витрины перестало бы совпадать с зерном источника, а вместе с ним развалилось
бы и правило формы — такую витрину уже пришлось бы материализовать.
Неочевидный и куда более разрушительный: `ARRAY JOIN` — соединение
внутреннее, и строку с пустым массивом он выбрасывает. Торговый блок пуст у
всех событий, кроме корзин и покупок, то есть у подавляющего большинства.
Так «плоская таблица событий» превращается в таблицу товарных позиций и
теряет девятнадцать строк из двадцати — а по имени и по составу колонок она
по-прежнему выглядит таблицей событий. Ошибка не падает и ничего не
подсвечивает.
Массивы едут в договор как есть; разворачивает их тот, кому нужна позиция
заказа, — и делает это уже в своей витрине, с другим зерном и другим именем.
## Как проверить себя
Деловой ключ — `event_id` внутри дня. Собери прямой ответ из `dds.event_v`
за тот же день, соедини с витриной полным внешним соединением и посчитай
строки, где ключа нет с одной из сторон или где различаются значения
деловых колонок. Ноль — зеленый.
Отдельно проверяются три производные колонки: `event_hour`, `page_path` и
`referer_host` должны совпасть с тем же выражением, посчитанным по
`dds.event_v` на лету.
Если строк в витрине оказалось в разы меньше, чем в источнике, а остались в
ней одни корзины и покупки — это развернутые массивы: `ARRAY JOIN` выбросил
все события с пустым торговым блоком.
## Приемка
- [ ] `dm.events_enriched_v` создано, физических таблиц под ним нет;
- [ ] список колонок в представлении явный;
- [ ] число строк и набор `event_id` совпадают с `dds.event_v` за каждый
прожитый день;
- [ ] задача `events_enriched` в `homework_check` зеленая;
- [ ] задачи в `dm_transform` **нет** — и не должно быть.
+121
View File
@@ -0,0 +1,121 @@
# Задание 3: `dm.session_overview`
**Чему учит:** агрегат хранит слагаемые, а не готовые средние и доли.
Среднее по строкам витрины не складывается никогда, а уникальных нельзя
складывать даже тогда, когда на сегодняшних данных сумма сходится.
## Роль витрины
Дневной портрет визитов в разрезе устройств: сколько было сессий, какие они
были длинные, сколько страниц смотрели и как часто доходили до покупки.
Вопрос витрины — «как люди ходят по магазину с телефона и с настольного
компьютера, и чем эти визиты отличаются».
Сессия на стенде — расчетная сущность, ее уже нарезал слой DDS. Собирать
визиты заново из событий не нужно и нельзя: это была бы вторая нарезка,
которая тихо разойдется с первой.
Витрина с таким именем была и на прошлом стенде, но зерно у нее сменилось,
и сменилось не по вкусу. Там визитов как сущности не было, и витрина
нарезала их сама, строкой на визит. Здесь визит — сущность слоя DDS, и
витрина, повторяющая его зерно, оказалась бы обычным представлением по
[правилу формы](../architecture/dm.md): копией сессий с другими именами
колонок. Чтобы задание осталось про дневной агрегат, витрина поднялась на
зерно выше — до дня и среза.
## Зерно и форма
Зерно — **день сессии × категория устройства**. Источник —
`dds.session_v`, у него зерно визита, значит зерно сменилось и витрина
материализуется: `dm.session_overview_rep` / `_dist`, донор `_stage_rep` /
`_stage_dist`, публичное `dm.session_overview_v`. Ключ партиции — день.
Категория устройства взята из сессии как есть: у куки она постоянна, и слой
DDS уже перевел код источника в слово. Словарь расшифровки шире, чем мир
стенда: слов в нем четыре, а встречаются три — `desktop`, `mobile` и
`tablet`. Строку под `tv` заводить незачем, она возникнет сама, если такие
визиты появятся.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `report_date` | `Date` | день сессии |
| `device_category` | `LowCardinality(String)` | категория устройства |
| `sessions` | `UInt64` | число визитов |
| `visitors` | `UInt64` | число разных `client_id` |
| `bounce_sessions` | `UInt64` | визиты ровно из одного события |
| `purchase_sessions` | `UInt64` | визиты, в которых была хотя бы одна покупка |
| `duration_seconds_total` | `UInt64` | суммарная длительность визитов |
| `events_total` | `UInt64` | событий во всех визитах |
| `pageviews` | `UInt64` | просмотров страниц |
| `cart_adds` | `UInt64` | добавлений в корзину |
| `purchases` | `UInt64` | покупок |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
Средней длительности и доли отказов в витрине нет намеренно. Хранить
`avg_duration_seconds` было бы удобнее ровно один раз — пока смотрят одну
строку. Тот, кто захочет среднее по дню целиком, сложит средние по трем
устройствам и получит неправду: средние не складываются, они взвешиваются.
Пара «`duration_seconds_total` и `sessions`» дает верный ответ на любом
срезе, а деление стоит потребителю один вызов. То же и с долей отказов:
храним `bounce_sessions` и `sessions`, долю считает тот, кто спрашивает.
С `visitors` история тоньше, и в ней главная ловушка задания. Складывать
уникальных по строкам нельзя: один и тот же посетитель, попавший в две
строки, будет посчитан дважды. Проверь на стенде — и увидишь, что сумма
`visitors` по устройствам за день **сходится** с `dm.daily_traffic_v`.
Это не отменяет правила, а показывает, откуда берется совпадение: в мире
стенда устройство приписано куке на всю жизнь ([словарь
понятий](../../CONTEXT.md), «Паспорт куки»), поэтому одна кука лежит ровно
в одной строке дня, и пересекаться строкам не в чем.
Совпадение это — свойство мира, а не свойство витрины. Появятся визиты
одной куки с двух устройств — сумма поедет, и ни одна проверка об этом не
скажет: отчет просто начнет тихо завышать аудиторию. Отсюда правило,
которое стоит унести с задания: складывать уникальных нельзя даже там, где
сегодня получается. Число посетителей дня спрашивают у
`dm.daily_traffic_v`, где оно посчитано на своем зерне.
## Ритм пересборки
Источник — сессии: день замерзает, как только прожит. Объем прогона тот же,
что в задании 2, — хвост от первого несобранного прожитого дня; механика та
же: донор и замена дневных партиций.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Здесь у измерения всего три встречающихся значения, и распределены
они неровно: планшеты — тонкая полоса, а телефоны и настольные машины держат
основную массу. Прежде чем брать хеш от одного `device_category`, посчитай,
сколько строк ляжет на каждый шард, и назови, чем тебя устраивает результат.
## Как проверить себя
Деловой ключ — `device_category` внутри дня. Собери прямой агрегат из
`dds.session_v` за день, соедини с `dm.session_overview_v` полным внешним
соединением и посчитай строки, где ключа нет с одной из сторон или где
расходится любой счетчик. Ноль — зеленый.
Две проверки покрупнее ловят грубую ошибку сразу:
- сумма `sessions` за день равна числу строк `dds.session_v` этого дня:
ни один визит не потерян и ни один не задвоен;
- сумма `events_total` за день равна сумме `events_total` по сессиям того
же дня.
Если суммы событий сходятся, а сумма сессий больше числа визитов — почти
наверняка витрина собрана из событий, а не из сессий.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] `dm.session_overview_v` перечисляет колонки явно;
- [ ] источник витрины — `dds.session_v`, своей нарезки визитов в ней нет;
- [ ] средних и долей в физической таблице нет;
- [ ] задача `session_overview` в `homework_check` зеленая;
- [ ] задача витрины добавлена в `dm_transform`.
+87
View File
@@ -0,0 +1,87 @@
# Задание 6 (необязательное): свой дашборд в Superset
**Чему учит:** довести свои витрины до потребителя — завести в Superset
датасеты поверх публичных представлений, подобрать графики под вопросы и
собрать из них один связный экран.
Эта лаба идет после заданий 1–5 и опирается на их результат. Ее можно
пропустить: основной предмет стенда — ClickHouse, а здесь вся работа — в
интерфейсе BI.
## Роль дашборда
Собери обзор трафика и поведения посетителей. Он должен отвечать на три
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
каналы дали результат, были ли ошибки приема данных.
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
собери из пяти витрин, которые ты построил в заданиях. Копировать раскладку
стендового дашборда не нужно: выбери форму, в которой ответы читаются без
пояснений рядом с экраном.
## Свои объекты — отдельно
Не добавляй графики в стендовый дашборд и не правь его датасеты. При
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
таких объектов описан в [доке Superset](../architecture/superset.md), «Импорт
при каждой инициализации».
Заведи отдельные датасеты и графики, собери из них свой дашборд. Назови его,
например, «Мои витрины DM». Это то же правило, по которому DDL домашек лежит
в `sql/ddl/70-labs.sql`: стенд привозит свои объекты, менти создает свои.
## Датасеты
Заведи в подключении `ClickHouse` пять физических датасетов схемы `dm`:
- `events_enriched_v`;
- `top_pages_daily_v`;
- `session_overview_v`;
- `dq_errors_daily_v`;
- `utm_effectiveness_v`.
## Состав дашборда
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
графика выбери сам. На дашборде покажи следующее:
| Витрина | Что показать |
|---|---|
| `events_enriched_v` | число событий по часу и типу события |
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
Для долей используй слагаемые из витрин. Например, доля отказов считается
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
долям для такого графика не подходит — урок задания 3 остается в силе.
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
## Как проверить себя
Эта лаба проверяется глазами. У расположения панелей и понятных подписей нет
счетного инварианта, поэтому задачи для нее в `homework_check` нет.
Открой список дашбордов и проверь:
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
- твой дашборд открывается без ошибок и содержит график от каждого из пяти
датасетов;
- подписи, единицы измерения и легенды читаются без обращения к SQL;
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
серверную;
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
Последний проход сделай как читатель: найди на экране самый заметный канал,
страницу и устройство, затем проверь, понятно ли из подписей, что именно
сравнивается. Если ответ приходится угадывать, поправь график или его название.
## Приемка
- [ ] пять датасетов читают публичные представления витрин заданий 1–5;
- [ ] создан отдельный дашборд, стендовые объекты не изменены;
- [ ] каждая витрина представлена хотя бы одним графиком;
- [ ] дашборд проверен глазами по разделу выше.
+114
View File
@@ -0,0 +1,114 @@
# Задание 2: `dm.top_pages_daily`
**Чему учит:** зерно сменилось — значит витрина материализуется, живет
дневными партициями и встает в конвейер. Первая настоящая витрина: пять
объектов, донор, замена партиции, задача в `dm_transform`.
## Роль витрины
Популярность страниц по дням: сколько раз страницу открыли, сколько разных
посетителей ее видели и что на ней делали кроме просмотра.
Торговое событие садится на ту страницу, где случилось: корзина — на
карточку товара, покупка — на страницу подтверждения заказа. Поэтому у
карточки товара рядом стоят просмотры и корзины, и их отношение —
конверсия карточки — главный вопрос к этой витрине: какие товары кладут в
корзину, едва открыв, а какие смотрят и уходят.
Воронки «просмотр → корзина → покупка» здесь нет и быть не может: корзина и
покупка живут на разных страницах, и связать их шаги в одну цепочку на
зерне страницы не из чего. Путь одного человека собирают из событий или из
сессий, а витрина страниц отвечает про страницу.
## Зерно и форма
Зерно — **день события × страница**. Источник — события, у него зерно
события, значит зерно сменилось и витрина материализуется:
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
публичное `dm.top_pages_daily_v`.
Страница — это `page_path`, путь `url` без домена и параметров запроса;
считается он здесь же, из колонки `url` источника. Полный адрес в зерно не
берется: с параметрами запроса одна и та же страница рассыпалась бы на
сотни строк, и витрина перестала бы отвечать на свой вопрос.
Ключ партиции — день: он же единица пересборки.
Источник — `dds.event_v`, публичный договор события. Витрина задания 1
источником не служит: витрина слоя DM читает договор DDS, как и стендовые
витрины, и не зависит от того, выполнена ли соседняя домашка.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `report_date` | `Date` | день события |
| `page_path` | `LowCardinality(String)` | путь страницы |
| `pageviews` | `UInt64` | число событий `pageview` на странице |
| `visitors` | `UInt64` | число разных `client_id` на странице |
| `cart_adds` | `UInt64` | число событий `add_to_cart` на странице |
| `purchases` | `UInt64` | число событий `purchase` на странице |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
Счетчики условные: строку дают все три типа событий, а различает их
`countIf`. Отфильтровать источник по `event_type = 'pageview'`, как
подсказывает имя витрины, — значит получить витрину из одних просмотров:
страницы останутся на месте, а `cart_adds` и `purchases` у всех строк
обнулятся. Ошибка тихая: витрина выглядит собранной, и главный ее вопрос —
конверсия карточки — молча отвечает «ноль везде».
## Ритм пересборки
Источник — события: день замерзает сразу, как только прожит. Значит
пересобирать нужно только то, что еще не собрано: хвост дней, которых в
витрине нет, — от первого несобранного прожитого дня до последнего
прожитого. Готовые дни трогать незачем, а вот дыру, оставленную частичным
отказом, объем прогона обязан подобрать: ищи не последний собранный день, а
первый несобранный.
Механика — сборка отрезка в донор одной вставкой и замена дневных партиций
по одной, как у `revenue_daily`.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Подумай, чем эта витрина отличается от `revenue_daily`: там кроме
дня было одно измерение из шести близких по объему значений, здесь —
страницы, которых много и которые по популярности очень неровные.
## Как проверить себя
Деловой ключ — `page_path` внутри дня. Собери прямой агрегат из
`dds.event_v` за день, соедини с `dm.top_pages_daily_v` полным внешним
соединением по ключу и посчитай строки, где ключа нет с одной из сторон или
где расходится любой из четырех счетчиков. Ноль — зеленый.
Две проверки попроще, ими удобно ловить грубую ошибку до полного
соединения:
- сумма `pageviews` за день равна числу событий `pageview` этого дня в
`dds.event_v`;
- сумма `purchases` за день равна числу событий `purchase` этого дня.
Проверки ловят разное. Фильтр `WHERE event_type = 'pageview'` первая из них
пропускает: отбор строк по типу не меняет счет событий того же типа.
Расхождение покажет только вторая — вместе с `cart_adds` обнулятся и
`purchases`. Проверку выбирают по тому, умеет ли она покраснеть на твоей
ошибке, а не по тому, как быстро она пишется: [проверка, которая не умеет
краснеть, бесполезна](../architecture/testing.md).
`visitors` складывать по строкам нельзя: один посетитель ходит по разным
страницам и в сумме посчитается несколько раз. Число посетителей дня
спрашивают у `dm.daily_traffic_v`, а не у этой витрины.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] `dm.top_pages_daily_v` перечисляет колонки явно, включая `_load_id` и
`_load_ts`;
- [ ] объем прогона выводится из состояния витрины, а не задается руками;
- [ ] задача `top_pages_daily` в `homework_check` зеленая;
- [ ] задача витрины добавлена в `dm_transform`, повторный прогон конвейера
результат не меняет.
+150
View File
@@ -0,0 +1,150 @@
# Задание 5: `dm.utm_effectiveness`
**Чему учит:** приставка `declared_` в имени колонки — предупреждение, а не
украшение. Она говорит: это заявка клиента, а не деньги отчетности. Урок
здесь дает конфликт: суммы не сойдутся, и раскапывать почему предстоит
самому.
Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по
бэкенду**. Эта витрина — единственное разрешенное исключение: атрибуции по
UTM у бэкенда нет, метки живут только в кликстриме. Раз считать иначе
нечем, клиентская сумма берется, но называется своим именем.
## Роль витрины
Эффективность рекламных каналов по дням: сколько трафика привел канал,
сколько корзин и покупок в нем случилось и на какую сумму эти покупки, по
словам клиента.
## Атрибуция: по событию, а не по визиту
Метки едут на каждом событии, и витрина группирует события по их
собственным меткам. Модели «первое касание» или «последнее касание» здесь
нет: их построение — отдельная тема, а это витрина трафика по меткам.
`dds.session_v` при этом несет UTM первого события визита, и ответ «какой
канал привел» из сессии и из этой витрины будет разным. Это не
ошибка ни одной из сторон — это два разных вопроса, и в отчете их надо
различать.
## Прямой трафик из витрины не выбрасывается
Соблазн отфильтровать строки без меток велик: витрина же про рекламу.
Отфильтровать нельзя по двум причинам. Первая деловая: прямой трафик —
обычно самый крупный «канал» магазина, и отчет, где его нет, врет о доле
рекламы. Вторая техническая: как только часть событий выпала, итог витрины
перестал сходиться с итогом источника, и проверить сборку стало нечем.
Пустые метки остаются пустыми строками, как в источнике. Своего слова для
«прямого захода» витрина не изобретает: чем меньше выдуманных значений, тем
меньше расшифровывать читателю.
## Зерно и форма
Зерно — **день события × `utm_source` × `utm_medium` × `utm_campaign`**.
Зерно источника — событие, значит витрина материализуется:
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
публичное `dm.utm_effectiveness_v`. Ключ партиции — день.
Источник — `dds.event_v`, публичный договор события: витрина слоя DM читает
договор DDS, а не соседнюю витрину.
## Состав колонок
| Колонка | Тип | Что это |
|---|---|---|
| `report_date` | `Date` | день события |
| `utm_source` | `LowCardinality(String)` | источник кампании |
| `utm_medium` | `LowCardinality(String)` | канал кампании |
| `utm_campaign` | `String` | имя кампании |
| `visitors` | `UInt64` | число разных `client_id` |
| `pageviews` | `UInt64` | просмотров страниц |
| `add_to_carts` | `UInt64` | добавлений в корзину |
| `purchases` | `UInt64` | покупок |
| `declared_revenue` | `Decimal(18, 2)` | выручка покупок глазами клиента |
| `_load_id` | `String` | запуск, собравший день |
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
`declared_revenue` складывает `purchase_revenue[1]` событий `purchase`.
Индекс не описка: блок `purchase*` едет параллельными массивами, и у события
покупки в них ровно один элемент — контракт источника обещает один номер
заказа и одну объявленную сумму на событие. Тем же индексом берет свою
сторону стендовая сверка `purchase_vs_orders`.
Приводить тип не нужно: в договоре `purchase_revenue` уже
`Array(Decimal(18, 2))`. `Float64` эта сумма была только в выгрузке
источника, и ODS перевел ее в точные деньги на приеме — сложение Decimal
точное, и где стоит `CAST`, до или после `sum`, на результат не влияет.
Витрина просто хранит то, что сложила, в `Decimal(18, 2)`.
`utm_campaign` оставлена обычной строкой: имен кампаний со временем
становится много, и словарь `LowCardinality` на растущем множестве
перестает окупаться.
## Ритм пересборки
Источник — события: день замерзает сразу. Объем прогона и механика те же,
что в задании 2: хвост несобранных дней, донор, замена дневных партиций.
## Ключ шардирования
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
шардам». Зерно здесь составное, из трех колонок, и решение «взять хеш от
одной из них» надо защищать отдельно: назови, какая доля строк придется на
самое частое значение выбранной колонки.
## Конфликт, ради которого витрина и написана
Собери за один и тот же день две суммы: `sum(declared_revenue)` из своей
витрины и `sum(revenue)` из `dm.revenue_daily_v`. Они не сойдутся, и это
верное поведение обеих витрин.
Базы у двух сумм при этом сравнимые: `revenue_daily` складывает цену позиций
на количество и ни скидки, ни доставки в себя не берет — ровно то, что видит
клиент. Значит все расхождение до копейки объясняется тем, что случилось с
заказом, а не тем, как считали деньги. Ищи ответы среди таких причин:
- отмененный заказ из кликстрима никуда не девается: событие покупки
случилось, а `revenue_daily` считает только оплаченные;
- событие покупки может потеряться по дороге, а заказ при этом доехать —
и наоборот, событие приехать дважды;
- клиент назвал сумму, отличную от серверной суммы позиций: генератор
делает это намеренно у одного-двух заказов из ста.
Как эти же расхождения выглядят на зерне заказа, показывает стендовая
витрина `dm.purchase_vs_orders_v` с ее классами расхождений: это готовый
ответ на вопрос «а сколько именно и из-за чего». Запиши свой вывод —
в двух-трех фразах, своими словами.
Проверять сходимость этих двух сумм не нужно и нельзя: они и не должны
сходиться.
## Как проверить себя
Деловой ключ — тройка `utm_source`, `utm_medium`, `utm_campaign` внутри
дня. Собери прямой агрегат из `dds.event_v` за день, соедини с
`dm.utm_effectiveness_v` полным внешним соединением и посчитай строки, где
ключа нет с одной из сторон или где расходится любая из пяти величин. Ноль —
зеленый.
Две проверки покрупнее ловят самые частые ошибки:
- сумма `pageviews`, `add_to_carts` и `purchases` за день равна числу
событий соответствующего типа в `dds.event_v` за тот же день;
- сумма `declared_revenue` за день равна сумме `purchase_revenue[1]` по всем
событиям `purchase` этого дня.
Обе не сойдутся, если прямой трафик отфильтрован, и разойдутся ровно на его
долю — по этому следу отфильтрованные строки и находят. `visitors`, как и в
задании 3, по строкам не складывается: один посетитель приходит по разным
меткам.
## Приемка
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
ключом цели;
- [ ] строки без UTM-меток в витрине остались;
- [ ] `declared_revenue` хранится в `Decimal(18, 2)`;
- [ ] расхождение с `dm.revenue_daily_v` объяснено письменно;
- [ ] задача `utm_effectiveness` в `homework_check` зеленая;
- [ ] задача витрины добавлена в `dm_transform`.
@@ -126,6 +126,10 @@ parseDateTime64InJodaSyntaxOrNull(
использовать и для типизированной строки, и для маршрутизации ошибки; нулевая использовать и для типизированной строки, и для маршрутизации ошибки; нулевая
дата не нужна. дата не нужна.
> Актуальный стенд: версию `ods.order` задаёт `snapshot_date`
> ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Следующий
> абзац фиксирует исходный вывод исследования и не описывает текущий контракт.
`updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse `updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse
явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной
версией версией
+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()` должен входить в ТЗ каждого вычитания отдельной строкой.
+57 -45
View File
@@ -138,8 +138,9 @@ Ecommerce (заполнены только у торговых событий):
| `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) | | `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) |
`add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав `add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав
заказа и блок `purchase*`. Выручка у клиента — во Float64, как у Метрики: заказа и блок `purchase*`. Выручка приходит JSON-числом Float64, как у
это не недосмотр, а часть урока о расхождениях (см. раздел 4). Метрики. ODS один раз нормализует деньги в `Array(Decimal(18, 2))`, не меняя
формат источника; DDS наследует тип хранения без нового вычисления.
### 1.3 Ключи и движки ### 1.3 Ключи и движки
@@ -210,9 +211,9 @@ Ecommerce (заполнены только у торговых событий):
ключей совпадает с порядком полей в таблице контракта, у позиции — `sku`, ключей совпадает с порядком полей в таблице контракта, у позиции — `sku`,
`qty`, `price`: так байты воспроизводимы без сортировки ключей. Разница с `qty`, `price`: так байты воспроизводимы без сортировки ключей. Разница с
кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами
и разбираются как `Array(Float64)`, а бэкенд передаёт деньги строками для Float64, а бэкенд передает деньги строками. На границе ODS оба источника
точного `Decimal`. Это показывает расхождение представлений денег в двух становятся `Decimal(18, 2)`: клиентское число разбирается сразу, без
источниках. промежуточного Float64 в хранилище.
Запись собирает явная функция существующего канонического сериализатора: Запись собирает явная функция существующего канонического сериализатора:
один словарь с вложенным списком и один `orjson.dumps`, без универсального один словарь с вложенным списком и один `orjson.dumps`, без универсального
слоя кодеков. слоя кодеков.
@@ -220,10 +221,12 @@ Ecommerce (заполнены только у торговых событий):
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md). [исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
- `ods.order` принимает версии заказа в - `ods.order` принимает версии заказа в
`ReplacingMergeTree(updated_at)`: `ORDER BY order_id`, партиция по дню `ReplacingMergeTree(snapshot_date)`: `ORDER BY order_id`, партиция по дню
неизменного `created_at`, шардирование по `cityHash64(order_id)`. неизменного `created_at`, шардирование по `cityHash64(order_id)`. Дата
`snapshot_date` остаётся датой наблюдения источника, но не задаёт публикацию слепка служит колонкой версии: выживает строка с максимальной
или идемпотентность. Физическое чтение может видеть несколько версий; `snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)).
Дата не доказывает полноту принятой порции, поэтому ODS не заменяет партицию
слепка целиком. Физическое чтение может видеть несколько версий;
`ods.order_v` возвращает точное текущее состояние. В DDS одна строка хранит `ods.order_v` возвращает точное текущее состояние. В DDS одна строка хранит
один заказ с позициями в типизированных массивах; физическая модель и один заказ с позициями в типизированных массивах; физическая модель и
пакетное наполнение описаны в [доке заказа](../architecture/dds/order.md). пакетное наполнение описаны в [доке заказа](../architecture/dds/order.md).
@@ -262,7 +265,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
| | Расхождение | Механика в генераторе | Ориентир доли | | | Расхождение | Механика в генераторе | Ориентир доли |
|---|---|---|---| |---|---|---|---|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе | | A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% | | B | Потерянное событие | заказ есть, `purchase` не доехал | бросок 3%, наблюдаемых ~1,8%: часть маячков защищена парами склейки, часть уходит в отмену приоритетом |
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% | | C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% |
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% | | D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
@@ -271,8 +274,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира; ориентир ~10%. Точные доли зафиксированы замером на каноническом мире
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и (28 дней, 2026-08-30, #194) — запись в «Что проверено»
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
только по дням с закрытым окном (раздел 8). только по дням с закрытым окном (раздел 8).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) — Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
@@ -283,7 +287,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM); под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
слово `declared` в имени — сигнал «это заявка клиента, не деньги слово `declared` в имени — сигнал «это заявка клиента, не деньги
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
раскопает почему (Float64 против Decimal, промокод, доставка, отмены). раскопает почему (состав позиций, промокод, доставка, отмены).
## 5. Анонимность и склейка идентичностей ## 5. Анонимность и склейка идентичностей
@@ -365,8 +369,10 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы, ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
опечатка иначе не падает. опечатка иначе не падает.
- **Политика соединений**: по ключу ко-локации — обычное соединение с - **Политика соединений**: по ключу ко-локации — обычное соединение с
комментарием, почему локальный результат корректен; по любому другому ключу комментарием, почему локальный результат корректен; если публичные
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка представления скрывают ко-локацию — решение записывается рядом с витриной.
По любому другому ключу — только явный GLOBAL; `NOT IN` — только
`GLOBAL NOT IN`. Сверка
`purchase`↔заказ — `purchase`↔заказ —
легитимная GLOBAL-витрина (заказы малы). легитимная GLOBAL-витрина (заказы малы).
- **Конвейер без TRUNCATE**: поток версий — в ReplacingMergeTree, точное чтение - **Конвейер без TRUNCATE**: поток версий — в ReplacingMergeTree, точное чтение
@@ -452,17 +458,13 @@ README.
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow — матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска. одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
Один следующий `task_id` двумя последовательными запросами пишет годные строки Один следующий `task_id` двумя последовательными запросами пишет годные строки
и ошибки из того же среза. `ods.order` хранит версии по `updated_at`, и ошибки из того же среза. `ods.order` выбирает текущую версию по максимальной
а не публикует партицию `snapshot_date`. Слой сырья остаётся точкой повтора и `snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Слой
разбора одной принятой порции. сырья остаётся точкой повтора и разбора одной принятой порции.
Стенд получает от этого два режима приёма рядом, поток и слепок, и сравнение
из опорных точек раздела 12 переформулировано под них.
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия: Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но у `ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
заказа бизнес-версию задаёт `updated_at`; `_load_id` проходит через STG и обе заказа задаёт дата слепка; `_load_id` проходит через STG и обе цели ODS. Таблицы
цели ODS. Таблицы
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды — `stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
без них урок «какая нода читала топик» ненаблюдаем. без них урок «какая нода читала топик» ненаблюдаем.
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
@@ -507,13 +509,20 @@ Kafka день переигрывается генератором заново:
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
`declared_revenue` по UTM. `declared_revenue` по UTM.
- **`daily_traffic_v`** — расширяется парой «посетители» / «известные - **`daily_traffic_v`** — расширяется парой «посетители» / «известные
пользователи» (обогащение через `dds.identity_map_v`, локальное соединение пользователи». Обогащение идет явным `GLOBAL LEFT JOIN` через
по ключу ко-локации). `dds.identity_map_v`: опыт на ClickHouse 26.3 показал, что публичные `_v`
скрывают ко-локацию от локальной подстановки.
- `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`, - `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`,
`dq_errors_daily_v` — переезжают на новую модель без смены роли: источник — `dq_errors_daily_v` — переезжают на новую модель без смены роли. Источник
`dds.event_v`, не `ods.event`. и зерно у двух из них меняет сама новая модель, и это зафиксировано ТЗ
- `dm.dq_summary` переводится с TRUNCATE+INSERT на партиционную замену домашек ([`docs/labs/`](../labs/README.md)):
(политика «без TRUNCATE»). `session_overview_v` читает `dds.session_v` и поднимается на зерно «день ×
срез», потому что визит стал сущностью DDS и витрина его зерна была бы
копией сессий; `dq_errors_daily_v` читает таблицы `ods.*_errors`, у
которых в DDS представительства нет. Остальные две читают `dds.event_v`,
а не `ods.event`.
- `dm.dq_summary` хранит текущее качество дня и обновляется партиционной
заменой (политика «без TRUNCATE»).
Дашборд Superset получает три новых сюжета: выручка по дням и категориям, Дашборд Superset получает три новых сюжета: выручка по дням и категориям,
таблица сверки с классами расхождений, пара посетители/известные. таблица сверки с классами расхождений, пара посетители/известные.
@@ -524,10 +533,11 @@ Kafka день переигрывается генератором заново:
## 8. Эталонный мир и опись ## 8. Эталонный мир и опись
В git хранится только опись эталонного мира; сам снимок (14 модельных В git хранится только опись эталонного мира; сам снимок генерируется на
дней) генерируется на месте — при `make up` и при проверках (решение месте — при `make up` и при проверках (решение развилки «Производительность»
развилки «Производительность» карты #26, подробности — карты #26, подробности — [спека генератора](2026-08-01-generator.md),
[спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней
снято на этапе 7. Опись несёт
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
@@ -547,8 +557,8 @@ Kafka день переигрывается генератором заново:
названного читателя в опись не кладётся названного читателя в опись не кладётся
([что хранит опись](../architecture/orders/inventory.md)). ([что хранит опись](../architecture/orders/inventory.md)).
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
при пересборке. [карты проверок](../architecture/testing.md).
## 9. Оценка объёма исполнения ## 9. Оценка объёма исполнения
@@ -610,8 +620,8 @@ v2 стартует пустым, поэтому объём ниже — это
выручка, сверка A+C. выручка, сверка A+C.
5. Airflow: `etl_pipeline` (партиционная переобработка). 5. Airflow: `etl_pipeline` (партиционная переобработка).
6. Расхождения B+D и опоздания; счётчики описи. 6. Расхождения B+D и опоздания; счётчики описи.
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация 7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация
на amd64 и arm64. снята (спека генератора, раздел 5).
8. Superset-дашборд v2. 8. Superset-дашборд v2.
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов 9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
и границы — ADR 0002. Этап идёт не подряд, см. ниже. и границы — ADR 0002. Этап идёт не подряд, см. ниже.
@@ -648,9 +658,15 @@ ADR 0002 с приёмочными совпадают почти целиком.
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом. расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной - Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
переносится. Спека даёт будущим лабам только опорные точки — контрольные переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг
числа описи (сверка, идентичность). Явное следствие: после этапа 9 самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и
стенд работает, но учебного пути на нём ещё нет. конвейер, в который она встает, проектируются вместе. Сюда же —
необязательная лаба «свой дашборд в Superset» (#197): она стоит на
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как
остальные лабы пока на уровне идей. Единый стиль блока
домашек — за работой учебного пути. Спека даёт будущим лабам только
опорные точки — контрольные числа описи (сверка, идентичность). Явное
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет.
- Инкрементальный ETL (#8) — свой issue. - Инкрементальный ETL (#8) — свой issue.
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд. - Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные - TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
@@ -686,10 +702,6 @@ Kafka Engine на двух нодах снят с этого списка при
`ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо `ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо
обезврежено, — работа без ответа на конце. обезврежено, — работа без ответа на конце.
- Точную форму локального обогащения `daily_traffic_v` через публичные `_v` —
эмпирически при проектировании витрин #135. Общее поведение соединения двух
Distributed-таблиц уже проверено в
[исследовании](../research/2026-08-22-distributed-join.md).
- Размер артефакта эталонного мира после пересборки. - Размер артефакта эталонного мира после пересборки.
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом - Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
сверять через MCP Context7 (правило AGENTS.md). сверять через MCP Context7 (правило AGENTS.md).
File diff suppressed because it is too large. Load diff
+26 -14
View File
@@ -20,8 +20,9 @@ DDL и двумя одноразовыми службами заливал во
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат **Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
прикладная схема, данные мира и позиция модельного времени, — и Compose, прикладная схема, данные мира и позиция модельного времени, — и Compose,
который держит только инфраструктуру. Зелёный `world_initialize` значит ровно который держит только инфраструктуру. Зеленая ветка создания в
одно: схема создана, оба источника приняты, следующий день известен. `world_initialize` подтверждает: схема создана, оба источника приняты,
следующий день известен, слои DDS и DM собраны.
## Решение ## Решение
@@ -106,11 +107,16 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
приехало» условием не считается. приехало» условием не считается.
7. Отправляет слепки заказов вторым прогоном образа — со сдвигом, дни 0…6 7. Отправляет слепки заказов вторым прогоном образа — со сдвигом, дни 0…6
([заказы в стартовом мире](../architecture/orders/start-world.md)). ([заказы в стартовом мире](../architecture/orders/start-world.md)).
8. Дёргает `orders_ingest` ждущим триггером и зеленеет только вслед за ним. 8. Дергает `orders_ingest` ждущим триггером и продолжает только после его
9. Последней успешной задачей ставит позицию в `WORLD_STARTING_DAYS`. успеха.
9. Ставит позицию в `WORLD_STARTING_DAYS`: источники приняты, мир уже создан.
10. Последним шагом дергает `etl_pipeline` ждущим триггером. Конвейер
последовательно ждет `dds_transform` и `dm_transform`; отказ слоя красит
инициализацию.
Порядок задач связывает проверки: каждый шаг подтверждён предыдущим, а красная Порядок задач связывает проверки: каждый шаг подтверждён предыдущим, а красная
задача называет место остановки. задача называет место остановки. Ветка "мир уже существует" конвейер не
запускает: обычный повтор `make up` по-прежнему ничего не меняет.
### `world_recreate` — снести мир и создать заново ### `world_recreate` — снести мир и создать заново
@@ -163,9 +169,15 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
недействителен и пересборку надо повторить. Стенд обязан назвать незавершённую недействителен и пересборку надо повторить. Стенд обязан назвать незавершённую
очистку вслух и не продолжать работу молча. очистку вслух и не продолжать работу молча.
**Очистка прошла, потомок не доработал.** Позиция остаётся `0`. Инициализация **Очистка прошла, создание остановилось до установки позиции.** Позиция
детерминирована, поэтому следующий `make up` просто пробует её заново — с остаётся `0`. Инициализация детерминирована, поэтому следующий `make up` просто
того же места и с тем же результатом. пробует её заново — с того же места и с тем же результатом.
**ETL упал после установки позиции.** Мир и оба источника уже готовы, поэтому
позиция остается в `WORLD_STARTING_DAYS`. После исправления запускают рукой
`etl_pipeline`: он сам выводит объем из состояния данных. Повторный
`world_initialize` здесь не поможет: при готовой позиции инициализация уйдет в
короткую ветку без изменений.
**Позицию в `WORLD_STARTING_DAYS` ставит только успех обоих источников.** **Позицию в `WORLD_STARTING_DAYS` ставит только успех обоих источников.**
События доехали, слепки приняты, `orders_ingest` зелёный — тогда и только тогда События доехали, слепки приняты, `orders_ingest` зелёный — тогда и только тогда
@@ -181,10 +193,11 @@ ODS истинно сразу — этим оно и ограничено, об
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
делает себе сам и тут же видит. делает себе сам и тут же видит.
**Слоты Airflow.** Их четыре, и вложенные ожидания в них укладываются, пока **Слоты Airflow.** Их четыре. При пересборке `world_recreate` ждет
пересборка не наложилась на движение мира: `world_recreate` ждёт `world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
`world_initialize`, тот ждёт `orders_ingest` — три слота из четырёх. Одновременно режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
работающий выключатель добавляет свои и загоняет стенд в тупик ожиданий. и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик.
Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live` Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live`
на паузу и даёт работникам доиграть. Предусловие остаётся в документации; на паузу и даёт работникам доиграть. Предусловие остаётся в документации;
механизм блокировок стоил бы дороже этой строки. механизм блокировок стоил бы дороже этой строки.
@@ -214,8 +227,7 @@ ODS истинно сразу — этим оно и ограничено, об
мир уже есть. мир уже есть.
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит - **`make rebuild-storage`** возвращает канонический мир: позиция проходит
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы `-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы
заново, офсеты групп идут с нуля. Цена прогона называется числом и сверяется заново, офсеты групп идут с нуля, DDS и DM собраны.
с прежними 2 м 25 с.
- **Красные пути проверяются своими отказами.** Сторож работника при - **Красные пути проверяются своими отказами.** Сторож работника при
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`; отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки, оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
+293
View File
@@ -0,0 +1,293 @@
# Программа учебных лаб
Эта спека собирает программу лаб из
[инвентаризации сюжетов](../research/2026-08-31-labs-inventory.md), выполненной
в тикете #201. Тикет спеки — #202, карта — #182.
Статус: спека принята владельцем; судьба идей-кандидатов решена здесь поименно.
## 1. Кому и зачем
Менти уже прошел большой теоретический курс ClickHouse объемом около 60 часов.
Он знает устройство системы в теории, но почти ничего не делал своими руками.
Поэтому в программе нет вводного блока с теорией. Здесь теория становится
ремеслом: менти руками осваивает слои хранилища, DQ, оркестрацию и
идемпотентность.
Обещание программы: менти проходит конвейер стенда автором, а не читателем.
## 2. Устройство программы: основная программа и факультативы
У частей программы два статуса, третьего нет.
**Основная программа** — обязательная часть. Менти проходит ее целиком и по
порядку, потому что каждая следующая ступень опирается на предыдущую. От
ступени к ступени растет самостоятельность: сначала менти получает готовые ТЗ с
готовыми сторожами (DQ-проверками), а затем строит сторожей сам.
**Факультативы** — необязательные лабы. Они собраны в тематические разделы,
чтобы менти мог одним взглядом выбрать интересное. Для каждого раздела указано,
после какой ступени основной программы он открывается.
В программе используются четыре формы заданий:
- *построить свое по ТЗ* — рядом есть образец, но готового ответа нет;
- *вычитание* — SQL готовой трансформации удаляется и пишется заново, а даги
остаются как несущая инфраструктура;
- *собери рядом* — менти строит свою копию результата рядом с нетронутым
стендовым и сверяет их построчно;
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а
менти ищет причину и объясняет ее.
## 3. Основная программа — семь ступеней
### Ступень 1. Построй пять витрин слоя DM
Первая часть программы опирается на шесть существующих заданий из
[`docs/labs/`](../labs/README.md). Пять ТЗ по витринам входят в первую ступень
основной программы и выполняются как есть. Их точечный пересмотр — работа
реализации. Шестое задание, про свой дашборд в Superset, остается факультативом
в разделе «Superset и BI». Набор уроков пяти витрин уже записан в их общем
описании: от слоя как договора до честных имен показателей.
Форма — построить свое по ТЗ. Обратную связь дает даг `homework_check`.
### Ступень 2. Свой отчет в Superset
Менти строит один график в Superset поверх витрины, которую сделал на первой
ступени. Урок — увидеть, как собственный результат из хранилища становится
отчетом в BI, не превращая знакомство с инструментом в отдельный большой
проект.
Форма — построить свое по ТЗ. Результат проверяется глазами. ТЗ для этой новой
ступени предстоит написать при реализации программы. Полный собственный дашборд
остается факультативом в разделе «Superset и BI».
### Ступень 3. Нарежь сессии сам
До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно
делит события на визиты. Сначала он сравнивает число своих сессий с эталоном
`VisitID`. Затем вручную соединяет свою сессию с эталонной и поштучно разбирает
хотя бы одно расхождение границ. Урок — счет называет проблему, но причину
приходится искать в отдельных строках.
Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит
из счетной сверки с `VisitID` и ручного сопоставления границ.
### Ступень 4. Перепиши нарезку стенда
Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку
и замену. Урок — изменить расчетную сущность внутри готового конвейера и
проверить ее внешним эталоном. Это единственный поток, для которого
инвентаризация подтвердила вычитание без оговорок: здесь есть настоящие места
для ошибок, а проверка не содержит готового ответа.
Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка,
построенная на третьей ступени.
### Ступень 5. Сначала сторож, потом ломай: заказы
Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство
«цена × количество = items_total». Вторая сверяет слой с источником по дням. Их
менти строит по образцу пяти стендовых проверок в `sql/dq/`. Обе проверки
должны покраснеть на управляемой ошибке. Затем менти вручную подкладывает по
строке каждого класса брака, проверяет разбор и убирает эти строки за собой.
Только после этого он вычитает разбор заказов ODS `order_parse` и трансформацию
заказа DDS. Урок — сторожа строят до рискованного изменения, а не после первой
поломки.
Форма — построить свои проверки по ТЗ, провести управляемое расследование и
выполнить вычитание. Обратную связь дают две новые проверки, баланс годных и
ошибочных строк по срезу и существующие сверки заказов.
### Ступень 6. Разбери сырые события и сверь результат со стендом
Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
отдельно от живого приема и использовать стендовый результат как образец для
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
сообщения.
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
границы называются прямо: срок работы ограничен окном хранения сырья, а
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
независимым эталоном.
### Ступень 7. Мониторинг: куда смотреть
Сначала менти знакомится с панелями стенда. Затем проводит два безопасных
расследования. В первом он находит в `query_log`, что верх списка тяжелых
запросов занимает сам мониторинг. Во втором объясняет, почему свежесть второго
источника выглядит как пила, а не как линия, и выбирает правильный слой данных
для панели.
Форма — расследование. Обратная связь — найденные запросы и верное объяснение
формы графика и выбора слоя. Ломательные опыты остаются в факультативе «Что
делать, когда стенд сломался». Урок ступени — куда и на что смотреть в живой
системе.
**Общее правило вычитания:** ТЗ каждого вычитания на ступенях 4 и 5 включает
пересчет ключа шардирования по `hostName()`. Счетные сверки читают `_dist`,
поэтому учитывают строку даже на чужом шарде и остаются зелеными: они не видят
саму ошибку раскладки.
## 4. Факультативы — пять разделов
Ниже перечислены только названия сюжетов и их уроки. Материал, формы и способы
проверки остаются в [инвентаризации](../research/2026-08-31-labs-inventory.md).
### Почему выручка не сходится
Этот раздел рекомендован первым из факультативов. Он открывается после ступени
1 основной программы: материал живет на стенде с этапа 6.
- **Почему трекер недосчитывает покупки (#186).** Разрыв воронки подтверждений
показывает потерянные события и границу доверия к двум источникам.
- **Почему повтор покупки завышает выручку (#187).** Движок убирает технический
дубль строки, а повтор одной покупки приходится находить по деловому ключу.
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
разложить до конкретной вычеркнутой позиции и объяснить до копейки.
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
некоторые различия после истечения срока сырья уже не восстановить.
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
становится окончательным лишь после того, как источник больше не может
изменить этот день.
- **Почему выручка за день еще меняется.** Внутри окна изменяемости выручка
движется, а после закрытия дня фиксируется.
### Тонкий анализ данных
Открывается после ступени 1 основной программы.
- **Что мы знали о пользователе в тот день (#145).** Текущее знание и знание на
дату дают разные отчеты; сведения из будущего меняют прошлое.
- **Добавь названия регионов в отчет (#143).** Менти собирает свой словарь по
образцу каталога и обогащает отчет через `dictGet`.
### Superset и BI
Открывается после ступени 2 основной программы.
- **Свой дашборд.** Готовая лаба
[`docs/labs/superset-dashboard.md`](../labs/superset-dashboard.md) учит
хранить пользовательские объекты отдельно от стендовых.
- **Почему дашборд не видит объект на второй ноде.** Забытый `ON CLUSTER` может
остаться незаметным до тех пор, пока дашборд не обратится ко второй ноде.
### Инженерия стенда
Открывается после ступени 1 основной программы. Сюжеты про переигрывание дня и
пошардовую атомарность раскроются полнее после ступени 4, когда менти уже
спускался в DDS.
- **Как принимать данные из Kafka: потоком или слепком.** Поток и слепок
требуют разных способов приема и дают разную наблюдаемость.
- **Почему данные попадают на свои шарды независимо от ноды чтения.**
Нода-потребитель может меняться, а раскладку строк определяет ключ
шардирования.
- **Почему вторая группа потребителей дублирует поток.** Еще одна группа
потребителей создает еще одну полную копию потока.
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
показывает потерю, которую трудно заметить по самим данным.
- **Почему сырье удаляется по времени загрузки.** Очистка сырья живет по
времени загрузки, а не по модельному дню.
- **Куда попадают битые строки.** Годные строки и брак делят поток без
пропусков и пересечений; менти подкладывает по строке каждого класса брака
событий и находит их в `ods.event_errors_dist`.
- **Какая версия заказа считается текущей.** Версию заказа задает слепок
источника, а `FINAL` скрывается за представлением.
- **Почему один момент попадает в разные дни.** Один момент дает разные дни в
разных поясах, поэтому день и пояс нужно называть явно.
- **Проверь, как стенд разобрал JSON в `ecommerce`.** Типизированный разбор
может молча подменить ошибку значением, поэтому менти сам разбирает сырой
JSON и сравнивает результат с массивами стенда.
- **Как конвейер восстанавливает пропущенный день.** Конвейер сам находит дыру
и определяет объем повторной обработки по состоянию хранилища.
- **Что будет, если день заменился не на всех шардах.** Замена дня завершается
отдельно на каждом шарде, поэтому целостность результата подтверждает
отдельная сверка.
- **Как обновлять витрину без полной пересборки (#149) — повышенная
сложность.** Экономия на пересчете требует хранить историю изменений, которую
полная пересборка стирает.
### Что делать, когда стенд сломался
Открывается после ступени 7 основной программы; весь раздел — повышенная
сложность.
- **Что ломается вместе с keeper.** Остановка keeper связывает состояние DDL и
репликации с тем, что видно на панелях и в системных таблицах.
- **Где искать запрос DDL, который не завершился.** Очередь распределенного DDL
показывает путь запроса и ответ каждой ноды.
Итог раздела — runbook, которого сейчас не хватает стенду. Менти пишет его по
результатам опытов, и документ остается в проекте.
## 5. Границы вычитания по потокам
Подробное сравнение потоков и таблица доказательств остаются в
[инвентаризации](../research/2026-08-31-labs-inventory.md). Спека берет это
сравнение за основу и проводит свои границы. Без оговорок вычитанию отдаются
только сессии DDS. Заказы можно вычитать лишь после самодельных сторожей. Для
событий ODS выбрана форма «собери рядом»; вычитание здесь отклонено. Прием STG
и забор заказов остаются несущей стеной и доступны только для расследований.
Карту идентичностей спека решает не отдавать вычитанию; причины этого выбора
названы в следующем разделе.
## 6. Что не вошло
Вычитание стендовых витрин DM не входит в программу. Оно повторяет урок
готового ядра DM, а открытые сверочные запросы в `sql/dq/` почти дословно
содержат ответ.
Форма «собери рядом» для карты идентичностей тоже не входит. Независимого
эталона нет, а слабые инварианты проверяют допустимость связей, но не полноту.
Аналитический урок идентичности уже несет склейка во времени (#145) в
факультативе «Тонкий анализ данных».
Сюжеты, которые инвентаризация относит к лекциям, остаются лекциями: `Sign` и
CollapsingMergeTree, топологии в бою и идентичность «как в бою». Эта спека
оставляет их в
[списке опорных точек раздела 12 спеки v2](2026-07-30-stand-v2-realism.md#опорные-точки-для-будущих-лекций-и-лаб).
## 7. Судьба идей-кандидатов
Эти решения закрывают тикеты после приемки спеки:
- #143, справочник регионов — вошла в факультатив «Тонкий анализ данных».
- #145, склейка во времени — вошла в факультатив «Тонкий анализ данных».
- #148, домашки ниже DM и вычитание — вошла в основную программу как ступени
4–6. Форму для каждого потока уточнила инвентаризация #201: вычитание,
«сначала сторож» или «собери рядом».
- #149, инкрементальный `daily_traffic` — вошла в факультатив «Инженерия
стенда» с пометкой «повышенная сложность».
- #186, воронка подтверждений — вошла в факультатив «Почему выручка не
сходится».
- #187, бизнес-дубль — вошла в факультатив «Почему выручка не сходится».
- #188, опоздавшие заказы — вошла в факультатив «Почему выручка не сходится».
## 8. Отклоненные варианты оси
- **Чистое «снизу вверх»: STG → ODS → DDS → DM.** Оно начинает программу с
самого тяжелого по инфраструктуре участка. Внизу быстро кончается эталон
обратной связи, а вычитание вслепую заставляет переписывать слой, которым
менти еще не пользовался.
- **Два прохода: сначала расследования снизу вверх, потом постройка сверху
вниз.** Пролог из восьми расследований откладывает первый собственный
результат. Вместо этого расследования разнесены по факультативам и
открываются по мере прохождения основной программы.
- **Сюжетами поперек слоев.** Такой порядок ломает принятое решение, по
которому готовые задания открывают программу, и превращает путь в сетку
зависимостей.
## 9. Границы спеки и что дальше
Спека не переписывает существующие ТЗ в `docs/labs/` и общий README; точечный
пересмотр относится к реализации.
Реализация программы тоже находится за границей спеки. В нее входят новые ТЗ
для ступеней 2–7 основной программы и факультативов, а также отражение порядка
программы в `docs/labs/README.md`. Карту #182 приемка этой спеки закрыла;
исполнение ведет отдельная карта — тикет #206 (решение тикета #203).
+6 -1
View File
@@ -1,4 +1,4 @@
.PHONY: lint typecheck test docs inventory .PHONY: lint typecheck test demo docs inventory
# --- Проверки --- # --- Проверки ---
@@ -12,6 +12,11 @@ typecheck:
test: test:
uv run pytest uv run pytest
# --- Пример из путеводителя ---
demo:
uv run python -m clickstream_generator.demo
# --- Сборка из кода --- # --- Сборка из кода ---
docs: docs:
+6 -25
View File
@@ -13,31 +13,11 @@
## Как это работает ## Как это работает
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
> выверен: пишет его тот, кто держит устройство генератора в голове, а это дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48. а день-функция проживает один день без накопленного состояния. Код этой
цепочки, ее схема и запуск D0 собраны в
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве [путеводителе](../docs/architecture/generator/README.md).
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
история для него не нужна и на него не влияет.
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
значит сдвинуть все броски после него, а с ними и весь мир: события того же
дня станут другими, счётчики канонического мира разойдутся с описью, и
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
`plan.cohort` брошены последними, а личность человека приписана уже за ними:
куки, пары и паспорта остались теми же до байта.
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
распределения numpy расходятся между версиями и архитектурами, а обещано
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
`rng.choice` с вероятностями.
## Что где лежит ## Что где лежит
@@ -104,6 +84,7 @@ D0 живёт предыстория, поэтому любой день соб
- `make test` — тесты генератора; - `make test` — тесты генератора;
- `make lint` — ruff: проверка и формат; - `make lint` — ruff: проверка и формат;
- `make typecheck` — ty: проверка типов; - `make typecheck` — ty: проверка типов;
- `make demo` — пройти стадии канонического D0 на одном экране;
- `make docs` — пересобрать описание выгрузки; - `make docs` — пересобрать описание выгрузки;
- `make inventory` — пересобрать опись мира. - `make inventory` — пересобрать опись мира.
+164 -11
View File
@@ -53,6 +53,7 @@
""" """
from dataclasses import dataclass from dataclasses import dataclass
from enum import IntEnum
from typing import Any from typing import Any
import numpy as np import numpy as np
@@ -160,6 +161,14 @@ class Purchases:
return self.person_id.size return self.person_id.size
class EventOutcome(IntEnum):
"""Что случилось с маячком purchase после создания заказа."""
DELIVERED = 0
LOST = 1
DUPLICATED = 2
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _Events: class _Events:
"""Строки одного вида торговых событий, готовые встать в поток. """Строки одного вида торговых событий, готовые встать в поток.
@@ -177,9 +186,18 @@ class _Events:
basket: NDArray[np.int64] basket: NDArray[np.int64]
# Что торговая половина отдаёт дню: поток целиком — колонки, страницы и @dataclass(frozen=True, slots=True)
# товары — и покупки дня вторым выходом. class Woven:
Woven = tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], Purchases] """Что торговая половина отдает дню после вплетения событий."""
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
purchases: Purchases
event_outcome: NDArray[np.int8]
# Для каждой покупки: назначен ли её заказ планом.
assigned_order: NDArray[np.bool_]
# День без единой корзины: покупок в нём нет, а форма у рядов есть. # День без единой корзины: покупок в нём нет, а форма у рядов есть.
_NO_PURCHASES = Purchases( _NO_PURCHASES = Purchases(
@@ -191,6 +209,8 @@ _NO_PURCHASES = Purchases(
revenue=np.empty(0, dtype=np.int64), revenue=np.empty(0, dtype=np.int64),
moment=np.empty(0, dtype="datetime64[s]"), moment=np.empty(0, dtype="datetime64[s]"),
) )
_NO_EVENT_OUTCOMES = np.empty(0, dtype=np.int8)
_NO_ASSIGNED_ORDERS = np.empty(0, dtype=np.bool_)
def weave( def weave(
@@ -200,6 +220,7 @@ def weave(
page: NDArray[np.uint8], page: NDArray[np.uint8],
product: NDArray[np.int64], product: NDArray[np.int64],
person: NDArray[np.uint64], person: NDArray[np.uint64],
assigned_order: NDArray[np.bool_],
) -> Woven: ) -> Woven:
"""Вплетает торговые события в поток и отдаёт поток и покупки дня. """Вплетает торговые события в поток и отдаёт поток и покупки дня.
@@ -207,14 +228,21 @@ def weave(
события встают между ними, и поток пересобирается одним порядком. Второй события встают между ними, и поток пересобирается одним порядком. Второй
выход — покупки дня: то же самое, чем они уехали в события. выход — покупки дня: то же самое, чем они уехали в события.
`person` — человек за кукой каждой строки, выровненный по входящему `person` и `assigned_order` выровнены по входящему потоку. Оба
потоку: личность приходит от плана состава, а не выводится из трекера признака приходят из плана состава: личность дает заказу `user_id`,
(docs/architecture/orders/identity.md). а назначение защищает мост склейки от потери purchase.
""" """
rng = day_stream(seed, day, Component.COMMERCE) rng = day_stream(seed, day, Component.COMMERCE)
baskets = _baskets(rng, page, product, columns["VisitID"]) baskets = _baskets(rng, page, product, columns["VisitID"])
if not len(baskets): if not len(baskets):
return columns, page, product, _NO_PURCHASES return Woven(
columns=columns,
page=page,
product=product,
purchases=_NO_PURCHASES,
event_outcome=_NO_EVENT_OUTCOMES,
assigned_order=_NO_ASSIGNED_ORDERS,
)
goods = catalog.catalog() goods = catalog.catalog()
draws = _draws(rng, baskets) draws = _draws(rng, baskets)
@@ -225,8 +253,20 @@ def weave(
_order_events(columns, baskets, goods, draws, revenue, coupons), _order_events(columns, baskets, goods, draws, revenue, coupons),
) )
columns, page, product, basket = _stream(rng, columns, page, product, events) columns, page, product, basket = _stream(rng, columns, page, product, events)
purchases = _purchases(columns, basket, baskets, draws, revenue, coupons, person) purchases, assigned = _purchases(
return columns, page, product, purchases columns, basket, baskets, draws, revenue, coupons, person, assigned_order
)
columns, page, product, event_outcome = _corrupt_events(
seed, day, columns, page, product, purchases, assigned
)
return Woven(
columns=columns,
page=page,
product=product,
purchases=purchases,
event_outcome=event_outcome,
assigned_order=assigned,
)
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws: def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
@@ -653,7 +693,8 @@ def _purchases(
revenue: NDArray[np.int64], revenue: NDArray[np.int64],
coupons: NDArray[np.object_], coupons: NDArray[np.object_],
person: NDArray[np.uint64], person: NDArray[np.uint64],
) -> Purchases: assigned_order: NDArray[np.bool_],
) -> tuple[Purchases, NDArray[np.bool_]]:
"""Покупки дня — то же, чем они уехали в события, только структурой. """Покупки дня — то же, чем они уехали в события, только структурой.
Порядок здесь — порядок строк потока, он же порядок номеров заказа: Порядок здесь — порядок строк потока, он же порядок номеров заказа:
@@ -664,7 +705,7 @@ def _purchases(
here = np.flatnonzero(rows["EventType"] == PURCHASE) here = np.flatnonzero(rows["EventType"] == PURCHASE)
mine = basket[here] mine = basket[here]
bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()] bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()]
return Purchases( purchases = Purchases(
order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()), order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()),
person_id=person[baskets.confirmation[mine]], person_id=person[baskets.confirmation[mine]],
product=tuple(baskets.product[group] for group in bought), product=tuple(baskets.product[group] for group in bought),
@@ -673,6 +714,118 @@ def _purchases(
revenue=revenue[mine], revenue=revenue[mine],
moment=rows["UTCEventTime"][here], moment=rows["UTCEventTime"][here],
) )
return purchases, assigned_order[baskets.confirmation[mine]]
def _corrupt_events(
seed: int,
day: int,
rows: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
purchases: Purchases,
assigned: NDArray[np.bool_],
) -> tuple[
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int8]
]:
"""Теряет purchase или повторяет пару подтверждения.
Заказы уже собраны, а номера покупок уже вписаны в строки. Поэтому
порча меняет только событийную сторону. Все броски делаются на полную
длину покупок дня, чтобы отбор одного класса не сдвигал другой.
"""
count = len(purchases)
if not count:
return rows, page, product, _NO_EVENT_OUTCOMES
rng = day_stream(seed, day, Component.BEACONS)
lost = (rng.integers(0, 100, count) < world.LOST_PURCHASE_PERCENT) & ~assigned
duplicate_draw = rng.integers(0, 100, count) < world.DUPLICATE_PURCHASE_PERCENT
delay = rng.integers(*world.DUPLICATE_DELAY_SECONDS, count)
watch = ids.unique_apart_from(rng, 2 * count, rows["WatchID"]).reshape(count, 2)
purchase_rows = np.flatnonzero(rows["EventType"] == PURCHASE)
confirmation_rows = _confirmation_rows(rows, page, purchase_rows)
eligible = _duplicate_eligibility(rows, purchase_rows, delay)
duplicated = ~lost & duplicate_draw & eligible
outcome = np.full(count, EventOutcome.DELIVERED, dtype=np.int8)
outcome[lost] = EventOutcome.LOST
outcome[duplicated] = EventOutcome.DUPLICATED
keep = np.ones(page.size, dtype=bool)
keep[purchase_rows[lost]] = False
sources = np.column_stack(
(confirmation_rows[duplicated], purchase_rows[duplicated])
)
selected = np.flatnonzero(duplicated)
if selected.size:
copies = sources.ravel()
shifted = np.repeat(delay[selected], 2).astype("timedelta64[s]")
additions = {name: values[copies] for name, values in rows.items()}
additions["UTCEventTime"] = additions["UTCEventTime"] + shifted
additions["WatchID"] = watch[selected].ravel()
# Первая строка пары — обновление confirmation: ее предыдущая
# страница — она же, а не checkout из первого просмотра.
additions["Referer"] = additions["URL"].copy()
rows = {
name: np.concatenate((values[keep], additions[name]))
for name, values in rows.items()
}
page = np.concatenate((page[keep], page[copies]))
product = np.concatenate((product[keep], product[copies]))
else:
rows = {name: values[keep] for name, values in rows.items()}
page = page[keep]
product = product[keep]
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
return (
{name: values[order] for name, values in rows.items()},
page[order],
product[order],
outcome,
)
def _confirmation_rows(
rows: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
purchase_rows: NDArray[np.int64],
) -> NDArray[np.int64]:
"""Находит просмотр, с которым purchase образует пару."""
confirmation = (rows["EventType"] == "pageview") & (page == Page.CONFIRMATION)
by_visit = {
int(rows["VisitID"][row]): row for row in np.flatnonzero(confirmation).tolist()
}
return np.array(
[by_visit[int(rows["VisitID"][row])] for row in purchase_rows.tolist()],
dtype=np.int64,
)
def _duplicate_eligibility(
rows: dict[str, NDArray[Any]],
purchase_rows: NDArray[np.int64],
delay: NDArray[np.int64],
) -> NDArray[np.bool_]:
"""Проверяет полночь и запас до следующего визита куки."""
moment = rows["UTCEventTime"][purchase_rows]
shifted = moment + delay.astype("timedelta64[s]")
before_midnight = shifted < _midnight(rows) + np.timedelta64(1, "D")
eligible = before_midnight.copy()
for number, row in enumerate(purchase_rows.tolist()):
later_visit = (
(rows["ClientID"] == rows["ClientID"][row])
& (rows["VisitID"] != rows["VisitID"][row])
& (rows["UTCEventTime"] > moment[number])
)
if np.any(later_visit):
next_start = rows["UTCEventTime"][later_visit].min()
eligible[number] &= next_start - moment[number] > np.timedelta64(
world.VISIT_TIMEOUT_SECONDS + int(delay[number]), "s"
)
return eligible
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64: def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
+10 -5
View File
@@ -100,6 +100,8 @@ class Day:
page: NDArray[np.uint8] page: NDArray[np.uint8]
product: NDArray[np.int64] product: NDArray[np.int64]
orders: orders.Orders orders: orders.Orders
# Событийный исход каждого заказа, в том же порядке.
purchase_outcome: NDArray[np.int8]
def __len__(self) -> int: def __len__(self) -> int:
return self.page.size return self.page.size
@@ -162,20 +164,23 @@ def stream(seed: int, day: int) -> Day:
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят # целиком: в нём же они и упорядочиваются. Вторым выходом приходят
# покупки дня — из них заказная сторона собирает заказы. # покупки дня — из них заказная сторона собирает заказы.
person = np.repeat(audience.person_id[visits.cookie], visits.pages) person = np.repeat(audience.person_id[visits.cookie], visits.pages)
columns, page, product, purchases = commerce.weave( assigned_order = np.repeat(visits.ordering, visits.pages)
woven = commerce.weave(
seed, seed,
day, day,
{name: value[order] for name, value in columns.items()}, {name: value[order] for name, value in columns.items()},
page[alive][order], page[alive][order],
product[alive][order], product[alive][order],
person[alive][order], person[alive][order],
assigned_order[alive][order],
) )
return Day( return Day(
day=day, day=day,
columns=columns, columns=woven.columns,
page=page, page=woven.page,
product=product, product=woven.product,
orders=orders.of_day(seed, day, purchases), orders=orders.of_day(seed, day, woven.purchases, woven.assigned_order),
purchase_outcome=woven.event_outcome,
) )
@@ -0,0 +1,62 @@
"""Короткий проход по цепочке генератора на каноническом дне D0."""
from collections import Counter
import numpy as np
from clickstream_generator import day, plan, seeds, world
DAY = 0
def _number(value: int) -> str:
"""Разделяет разряды числа для чтения с экрана."""
return f"{value:,}".replace(",", " ")
def render() -> str:
"""Собирает D0 и показывает его стадии без числового эталона."""
seed = seeds.CANONICAL_SEED
cohort = plan.cohort(seed, DAY)
audience = plan.audience(seed, DAY)
events = day.stream(seed, DAY)
_, rows_per_visit = np.unique(events.columns["VisitID"], return_counts=True)
event_types = Counter(str(value) for value in events.columns["EventType"])
smallest_visit = int(rows_per_visit.min())
largest_visit = int(rows_per_visit.max())
return "\n".join(
(
f"Зерно: {seed}",
"Подпотоки D0: состав мира → когорта; день → трафик, торговля, заказы",
(
f"Когорта D0: людей — {_number(cohort.people)}; "
f"кук — {_number(len(cohort.client_id))}"
),
(
f"Аудитория D0: кук — {_number(len(audience.client_id))}; "
f"когорты D-{world.RETURN_TAIL_DAYS}...D0"
),
(
f"Визиты по VisitID: групп — {_number(len(rows_per_visit))}; "
f"строк в группе — {smallest_visit}–{largest_visit}"
),
(
f"Строки событий: {_number(len(events))} "
f"(pageview — {_number(event_types['pageview'])}; "
f"add_to_cart — {_number(event_types['add_to_cart'])}; "
f"purchase — {_number(event_types['purchase'])}); "
f"заказов — {_number(len(events.orders))}"
),
)
)
def main() -> None:
"""Печатает проход по D0."""
print(render())
if __name__ == "__main__":
main()
@@ -8,8 +8,9 @@
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7. понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
Эталонный снимок в четырнадцать дней придёт на этапе 7 и станет продолжением Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
этой же описи, а не вторым файлом. у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот
же файл, а не заведет второй.
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем **Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
@@ -119,17 +120,38 @@ def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]
def _order_class_counts(today: day_module.Day) -> dict[str, int]: def _order_class_counts(today: day_module.Day) -> dict[str, int]:
"""Заказы дня по итоговому классу: отмена перевешивает дельту суммы.""" """Заказы дня по итоговому классу по приоритету спеки."""
purchase = today.columns["EventType"] == commerce.PURCHASE purchase = today.columns["EventType"] == commerce.PURCHASE
declared = today.columns["purchaseRevenue"][purchase] declared = {
counts = {"match": 0, "cancelled": 0, "amount_delta": 0} purchase_id[0]: round(revenue[0] * commerce.KOPECKS)
for purchase_id, revenue in zip(
today.columns["purchaseID"][purchase],
today.columns["purchaseRevenue"][purchase],
strict=True,
)
}
counts = {
"match": 0,
"cancelled": 0,
"lost_event": 0,
"duplicate_event": 0,
"amount_delta": 0,
}
for outcome, items_total, revenue in zip( for order_id, order_outcome, event_outcome, items_total in zip(
today.orders.outcome, today.orders.items_total, declared, strict=True today.orders.order_id,
today.orders.outcome,
today.purchase_outcome,
today.orders.items_total,
strict=True,
): ):
if outcome != orders_module.OrderOutcome.PAID: if order_outcome != orders_module.OrderOutcome.PAID:
counts["cancelled"] += 1 counts["cancelled"] += 1
elif items_total != round(revenue[0] * commerce.KOPECKS): elif event_outcome == commerce.EventOutcome.LOST:
counts["lost_event"] += 1
elif event_outcome == commerce.EventOutcome.DUPLICATED:
counts["duplicate_event"] += 1
elif items_total != declared[order_id]:
counts["amount_delta"] += 1 counts["amount_delta"] += 1
else: else:
counts["match"] += 1 counts["match"] += 1
+31 -6
View File
@@ -24,11 +24,13 @@
одной доли перебрасывала бы весь подпоток после себя одной доли перебрасывала бы весь подпоток после себя
(docs/architecture/orders/fate.md). (docs/architecture/orders/fate.md).
**Судьба заказа — исход и его моменты**: из окна изменяемости заказ выходит **Судьба заказа — исход, его моменты и задержка выгрузки**. Из окна
оплаченным или отменённым, а когда именно это случилось, сказано смещением в изменяемости заказ выходит оплаченным или отменённым, а когда именно это
секундах от рождения заказа. Момента, которого у исхода нет, нет и в данных: случилось, сказано смещением в секундах от рождения заказа. Момента, которого
его место занимает −1, а не ноль, — иначе «оплатили в секунду рождения» было у исхода нет, нет и в данных: его место занимает −1, а не ноль, — иначе
бы не отличить от «не оплатили вовсе». «оплатили в секунду рождения» было бы не отличить от «не оплатили вовсе».
Задержка говорит, сколько ранних слепков заказ пропустит; назначенный планом
приезжает сразу.
**Состояние на границе суток** — чтение готовой судьбы, а не накопление: **Состояние на границе суток** — чтение готовой судьбы, а не накопление:
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
@@ -62,6 +64,7 @@ _DELIVERY_CUMULATIVE = np.cumsum(
) )
_OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS) _OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS)
_MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS) _MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS)
_SNAPSHOT_DELAY_CUMULATIVE = np.cumsum(world.ORDER_SNAPSHOT_DELAY_WEIGHTS)
class OrderOutcome(IntEnum): class OrderOutcome(IntEnum):
@@ -103,12 +106,18 @@ class Orders:
outcome: NDArray[np.int64] outcome: NDArray[np.int64]
paid_after: NDArray[np.int64] paid_after: NDArray[np.int64]
cancelled_after: NDArray[np.int64] cancelled_after: NDArray[np.int64]
snapshot_delay: NDArray[np.int64]
def __len__(self) -> int: def __len__(self) -> int:
return self.user_id.size return self.user_id.size
def of_day(seed: int, day: int, purchases: Purchases) -> Orders: def of_day(
seed: int,
day: int,
purchases: Purchases,
assigned_order: NDArray[np.bool_],
) -> Orders:
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги.""" """Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
rng = day_stream(seed, day, Component.ORDERS) rng = day_stream(seed, day, Component.ORDERS)
delivery = _delivery(rng, len(purchases)) delivery = _delivery(rng, len(purchases))
@@ -116,6 +125,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
product, quantity, items_total = _delta(rng, purchases) product, quantity, items_total = _delta(rng, purchases)
created_at = _created_at(rng, purchases) created_at = _created_at(rng, purchases)
discount = _discount(purchases, items_total) discount = _discount(purchases, items_total)
snapshot_delay = _snapshot_delay(rng, assigned_order)
return Orders( return Orders(
day=day, day=day,
order_id=purchases.order_id, order_id=purchases.order_id,
@@ -130,6 +140,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
outcome=outcome, outcome=outcome,
paid_after=paid_after, paid_after=paid_after,
cancelled_after=cancelled_after, cancelled_after=cancelled_after,
snapshot_delay=snapshot_delay,
) )
@@ -166,6 +177,11 @@ def at_boundary(rows: Orders, day: int) -> tuple[list[str], NDArray[np.datetime6
return status.tolist(), updated return status.tolist(), updated
def arrived(rows: Orders, day: int) -> NDArray[np.bool_]:
"""Какие заказы уже попали в выгрузку к слепку дня `day`."""
return rows.day + rows.snapshot_delay <= day
def _boundary(day: int) -> np.datetime64: def _boundary(day: int) -> np.datetime64:
"""Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика. """Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика.
@@ -218,6 +234,15 @@ def _created_at(
) )
def _snapshot_delay(
rng: np.random.Generator,
assigned_order: NDArray[np.bool_],
) -> NDArray[np.int64]:
"""Сколько ранних слепков пропустит заказ; назначенный приезжает сразу."""
delay = pick(rng, _SNAPSHOT_DELAY_CUMULATIVE, assigned_order.size)
return np.where(assigned_order, 0, delay)
def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]: def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём. """Момент внутри окна: час по таблице весов и равномерная секунда в нём.
@@ -12,12 +12,12 @@
- `name` — имя источника, как в облачной выгрузке Метрики; сырой слой хранит - `name` — имя источника, как в облачной выгрузке Метрики; сырой слой хранит
его без изменений. его без изменений.
- `clickhouse_type` — тип в хранилище ровно в той записи, в какой его вернёт - `clickhouse_type` — тип колонки у источника в той записи, которой пользуется
`system.columns`: параметры входят в имя типа целиком, без сокращений ClickHouse: параметры входят в имя целиком, без сокращений
(`LowCardinality(String)`, `Array(Float64)`). Сверено 2026-08-01 — (`LowCardinality(String)`, `Array(Float64)`). Обычно ODS берет его без
по документации ClickHouse через Context7 и запросом к узлу стенда изменений. Исключение одно: денежный `purchaseRevenue` приезжает как
(26.3.17.56). Запись важна потому, что по ней человек пишет DDL: тип, Float64, а на границе хранения разбирается прямо в Decimal. Поэтому поле
сокращённый здесь, приедет в таблицу сокращённым же. описывает выгрузку, а локальный тип ODS назван в документации хранилища.
- `numpy_dtype` — чем колонка представлена внутри генератора; у массивов это - `numpy_dtype` — чем колонка представлена внутри генератора; у массивов это
тип элемента. Строки живут в `object`-массивах: numpy-строки фиксированной тип элемента. Строки живут в `object`-массивах: numpy-строки фиксированной
длины стенду ничего не дают. длины стенду ничего не дают.
@@ -371,8 +371,8 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="float64", numpy_dtype="float64",
normalized_name="purchase_revenue", normalized_name="purchase_revenue",
group=ColumnGroup.ECOMMERCE, group=ColumnGroup.ECOMMERCE,
comment="выручка заказа глазами клиента; Float64, как у Метрики —" comment="выручка заказа глазами клиента; источник объявляет Float64,"
" на этом держится урок о расхождениях с бэкендом", " ODS хранит точный Decimal",
), ),
Column( Column(
name="purchaseCurrency", name="purchaseCurrency",
@@ -27,10 +27,12 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки. собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и Имена и типы колонок описывают сторону источника. Обычно ODS принимает их без
нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное изменений. Исключение — `purchaseRevenue`: источник объявляет Float64, а ODS
имя». Это имя источника, приведённое к snake_case, а не имя атрибута в хранит деньги в Decimal. Точный локальный тип назван в документации
модели данных: слой DDS складывает свою модель и называет атрибуты по ней. хранилища. Имя в нашем стиле ждет в столбце «Нормализованное имя». Это имя
источника, приведенное к snake_case, а не имя атрибута в модели данных: слой
DDS складывает свою модель и называет атрибуты по ней.
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора; Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
контрактом. контрактом.
+3 -5
View File
@@ -7,7 +7,7 @@
│ ├── ось → номер дня: когорта этого дня │ ├── ось → номер дня: когорта этого дня
│ └── предыстория → глубина: когорта дня до D0 │ └── предыстория → глубина: когорта дня до D0
└── дни └── дни
└── номер дня → трафик, торговля, заказная сторона, опоздания └── номер дня → трафик, торговля, заказы, маячки счетчика
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7 (зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
@@ -40,10 +40,8 @@ class Component(IntEnum):
# каждого заказа читает из его собственного дня # каждого заказа читает из его собственного дня
# (docs/architecture/orders/fate.md). # (docs/architecture/orders/fate.md).
ORDERS = 2 ORDERS = 2
# Событийную сторону — потерю и дубль события — заведёт этап 6; до тех # Маячки счетчика: потеря и повторная отправка событий.
# пор позиция занята мёртвым именем прежней нарезки, и из него никто не BEACONS = 3
# бросает.
LATECOMERS = 3
class _Branch(IntEnum): class _Branch(IntEnum):
@@ -45,7 +45,7 @@ from numpy.typing import NDArray
from clickstream_generator import catalog, schema, world from clickstream_generator import catalog, schema, world
from clickstream_generator.day import Day from clickstream_generator.day import Day
from clickstream_generator.orders import Orders, at_boundary from clickstream_generator.orders import Orders, arrived, at_boundary
_ARRAY_PREFIX = "Array(" _ARRAY_PREFIX = "Array("
@@ -72,9 +72,10 @@ def events(day: Day, limit: int | None = None) -> list[bytes]:
def orders(window: Sequence[Orders], day: int) -> list[bytes]: def orders(window: Sequence[Orders], day: int) -> list[bytes]:
"""Канонические байты слепка дня `day`: по документу JSON на заказ. """Канонические байты слепка дня `day`: по документу JSON на заказ.
`window` — заказы дней окна, от раннего дня к позднему: слепок несёт их `window` — заказы дней окна, от раннего дня к позднему. Уже приехавшие
подряд, и порядок строк выходит порядком рождения заказов, он же заказы слепок несёт подряд, поэтому порядок строк остаётся порядком
возрастание `order_id`. Какие это дни, решает `orders.window`. рождения, он же возрастание `order_id`. Какие это дни, решает
`orders.window`.
Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они
станут `Decimal`, и дробь двоичного числа была бы потерей точности до станут `Decimal`, и дробь двоичного числа была бы потерей точности до
@@ -97,7 +98,8 @@ def orders(window: Sequence[Orders], day: int) -> list[bytes]:
delivery = rows.delivery.tolist() delivery = rows.delivery.tolist()
total = rows.total.tolist() total = rows.total.tolist()
for number, order_id in enumerate(rows.order_id): for number in np.flatnonzero(arrived(rows, day)).tolist():
order_id = rows.order_id[number]
payloads.append( payloads.append(
orjson.dumps( orjson.dumps(
{ {
+26 -9
View File
@@ -169,6 +169,16 @@ BUYER_CHECKOUT_OF_CART_PERCENT = 60
# страницу, на которой посетитель его нажал. # страницу, на которой посетитель его нажал.
TRADE_DELAY_SECONDS = (2, 8) TRADE_DELAY_SECONDS = (2, 8)
# Событийная сторона сверки: потерянные маячки purchase и повторная
# отправка пары «просмотр подтверждения + purchase». Замер (28 дней,
# канонический мир, 2026-08-30): дубль 1,89% — в пределах ориентира ~2%;
# потеря 1,78% при броске 3% — так и задумано: назначенные планом покупки
# из броска исключены (мост склейки), часть съедает приоритет отмены;
# наблюдаемая доля принята решением владельца (#194).
LOST_PURCHASE_PERCENT = 3
DUPLICATE_PURCHASE_PERCENT = 2
DUPLICATE_DELAY_SECONDS = (30, 301)
# Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла. # Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла.
# Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`): # Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`):
# магнит, обычный, залёживается. # магнит, обычный, залёживается.
@@ -238,8 +248,8 @@ GOAL_PURCHASE_ID = 42150002
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет # обычная курьерская, срочная. Это единственные деньги заказа, которых нет
# ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду # ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду
# считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок # считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок
# заказной стороны. Числа черновые: калибровка — пересборка эталонного # заказной стороны. Замер (28 дней, канонический мир, 2026-08-30): 35,5% /
# мира, этап 7. # 49,2% / 15,4% — совпадает с весами.
DELIVERY_KOPECKS_WEIGHTS = ( DELIVERY_KOPECKS_WEIGHTS = (
(0, 35), (0, 35),
(29_900, 50), (29_900, 50),
@@ -248,12 +258,13 @@ DELIVERY_KOPECKS_WEIGHTS = (
# Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке # Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке
# `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ # `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ
# выходит либо оплаченным, либо отменённым. Доли черновые: калибровка — # выходит либо оплаченным, либо отменённым. Замер (28 дней, 2026-08-30):
# пересборка эталонного мира, этап 7. # отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12% отменен
# неоплаченным; ориентир мастер-спеки ~5%.
ORDER_OUTCOME_WEIGHTS = ( ORDER_OUTCOME_WEIGHTS = (
95, # оплачен 95, # оплачен
3, # оплачен, потом отменён 3, # оплачен, потом отменён
2, # отменён неоплаченным 2, # отменен неоплаченным
) )
# Когда внутри окна случается момент заказа — оплата или отмена: веса часов # Когда внутри окна случается момент заказа — оплата или отмена: веса часов
@@ -261,8 +272,8 @@ ORDER_OUTCOME_WEIGHTS = (
# с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа, # с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа,
# рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в # рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в
# первые сутки, дальше затухание до края: # первые сутки, дальше затухание до края:
# профиль гаснет, поэтому обрыв окна в данных не виден. Веса черновые, как и # профиль гаснет, поэтому обрыв окна в данных не виден. Веса зафиксированы
# доли исходов. # вместе с долями исходов — тем же замером выше.
ORDER_MOMENT_HOUR_WEIGHTS = tuple( ORDER_MOMENT_HOUR_WEIGHTS = tuple(
weight weight
for hours, weight in ((6, 100), (18, 60), (24, 25), (48, 8), (48, 2)) for hours, weight in ((6, 100), (18, 60), (24, 25), (48, 8), (48, 2))
@@ -273,6 +284,12 @@ ORDER_MOMENT_HOUR_WEIGHTS = tuple(
# наличии, и заказ приезжает на строку короче клиентской корзины. Бросается # наличии, и заказ приезжает на строку короче клиентской корзины. Бросается
# доля на все заказы дня, а достаётся дельта не всем — однопозиционному заказу # доля на все заказы дня, а достаётся дельта не всем — однопозиционному заказу
# терять нечего, и таких больше половины, — поэтому в сверке наблюдаемая доля # терять нечего, и таких больше половины, — поэтому в сверке наблюдаемая доля
# выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Число # выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Замер
# черновое: калибровка — пересборка эталонного мира, этап 7. # (28 дней, 2026-08-30): наблюдаемая доля 1,32% — в пределах ориентира.
ORDER_DELTA_PERCENT = 4 ORDER_DELTA_PERCENT = 4
# На сколько первых дневных слепков заказ опоздает: веса δ = 0, 1, 2.
# Назначенные планом заказы после броска получают 0, чтобы мост склейки
# появился в стартовом мире. Замер (28 дней, 2026-08-30): доля опоздавших
# (δ>0) 8,39% — в пределах ориентира мастер-спеки ~10%.
ORDER_SNAPSHOT_DELAY_WEIGHTS = (90, 5, 5)
+123 -25
View File
@@ -21,7 +21,7 @@ from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2 WEEKDAY = 2
WEEK = 7 WEEK = 7
# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком — # Окно замера — две недели: доли по уровням спроса меряются на нем целиком —
# на одном дне у самого редкого уровня набирается слишком мало карточек. # на одном дне у самого редкого уровня набирается слишком мало карточек.
FORTNIGHT = 14 FORTNIGHT = 14
@@ -133,15 +133,15 @@ def opened_cards(events: day.Day) -> set[tuple[int, int]]:
) )
def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day): def test_only_a_lost_purchase_leaves_a_confirmation_without_purchase(
"""Клиентская сторона честная: подтверждение без покупки — это потеря. weekday: day.Day,
):
Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их """Потеря выбрасывает purchase, но оставляет просмотр confirmation."""
быть не должно ни одной. Обратное тоже верно: покупка без подтверждения
в потоке — заказ ниоткуда.
"""
bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist()) bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist())
assert confirmed_visits(weekday) == bought missing = confirmed_visits(weekday) - bought
lost = int((weekday.purchase_outcome == commerce.EventOutcome.LOST).sum())
assert len(missing) == lost > 0
assert bought <= confirmed_visits(weekday)
def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase( def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
@@ -159,7 +159,10 @@ def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
events = day.stream(CANONICAL_SEED, WEEKDAY) events = day.stream(CANONICAL_SEED, WEEKDAY)
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist()) bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
assert bought assert bought
assert confirmed_visits(events) == bought missing = confirmed_visits(events) - bought
lost = int((events.purchase_outcome == commerce.EventOutcome.LOST).sum())
assert len(missing) == lost
assert bought <= confirmed_visits(events)
def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day): def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day):
@@ -525,13 +528,15 @@ def test_the_goals_repeat_the_trade_events(weekday: day.Day):
def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day): def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day):
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда.""" """Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
orders = rows_of(weekday, commerce.PURCHASE) events = rows_of(weekday, commerce.PURCHASE)
numbers = [cell[0] for cell in orders["purchaseID"]] numbers = [cell[0] for cell in events["purchaseID"]]
date = str(weekday.columns["EventDate"][0]).replace("-", "") date = str(weekday.columns["EventDate"][0]).replace("-", "")
assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)] expected = [f"{date}-{place:04d}" for place in range(1, len(weekday.orders) + 1)]
assert len(set(numbers)) == len(numbers) # Порча идет после нумерации: потеря оставляет дыру, а дубль
# Нумерация идёт в порядке событий: строки дня уже упорядочены по времени. # повторяет готовый номер и не двигает соседей.
assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"]) assert list(weekday.orders.order_id) == expected
assert set(numbers) <= set(expected)
assert list(events["UTCEventTime"]) == sorted(events["UTCEventTime"])
def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue( def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue(
@@ -599,7 +604,23 @@ def test_the_trade_flow_draws_from_its_own_named_branch(
monkeypatch.setattr(commerce, "day_stream", spy) monkeypatch.setattr(commerce, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY) day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)] assert asked == [
(CANONICAL_SEED, WEEKDAY, Component.COMMERCE),
(CANONICAL_SEED, WEEKDAY, Component.BEACONS),
]
def test_an_assigned_purchase_is_never_lost(monkeypatch: pytest.MonkeyPatch):
"""Гарантия плана сильнее порчи: мост склейки не теряется."""
monkeypatch.setattr(world, "LOST_PURCHASE_PERCENT", 100)
monkeypatch.setattr(world, "DUPLICATE_PURCHASE_PERCENT", 0)
events = day.stream(CANONICAL_SEED, WEEKDAY)
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
promised = set(audience.client_id[audience.assigned_order].tolist())
bought = set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist())
assert promised
assert bought == promised
def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]): def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
@@ -627,6 +648,78 @@ def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs
def test_a_duplicate_repeats_the_confirmation_story(weekday: day.Day):
"""Обновление повторяет просмотр и purchase в том же визите."""
purchases = rows_of(weekday, commerce.PURCHASE)
ids_by_order: dict[str, list[int]] = {}
for row, cell in enumerate(purchases["purchaseID"]):
ids_by_order.setdefault(str(cell[0]), []).append(row)
duplicated = [rows for rows in ids_by_order.values() if len(rows) == 2]
expected = int((weekday.purchase_outcome == commerce.EventOutcome.DUPLICATED).sum())
assert len(duplicated) == expected > 0
for pair in duplicated:
first, second = sorted(pair, key=lambda row: purchases["UTCEventTime"][row])
delay = (
(purchases["UTCEventTime"][second] - purchases["UTCEventTime"][first])
.astype("timedelta64[s]")
.astype(int)
)
assert world.DUPLICATE_DELAY_SECONDS[0] <= delay
assert delay < world.DUPLICATE_DELAY_SECONDS[1]
assert delay < world.VISIT_TIMEOUT_SECONDS
assert purchases["VisitID"][first] == purchases["VisitID"][second]
assert purchases["WatchID"][first] != purchases["WatchID"][second]
for name in (
"purchaseID",
"purchaseRevenue",
"productID",
"productQuantity",
"ecommerce",
):
left, right = purchases[name][first], purchases[name][second]
if isinstance(left, np.ndarray):
assert np.array_equal(left, right), name
else:
assert left == right, name
visit = purchases["VisitID"][first]
confirmation = (
(weekday.columns["EventType"] == "pageview")
& (weekday.page == Page.CONFIRMATION)
& (weekday.columns["VisitID"] == visit)
)
views = np.flatnonzero(confirmation)
assert views.size == 2
assert len(set(weekday.columns["WatchID"][views].tolist())) == 2
view_delay = (
(
weekday.columns["UTCEventTime"][views].max()
- weekday.columns["UTCEventTime"][views].min()
)
.astype("timedelta64[s]")
.astype(int)
)
assert view_delay == delay
cookie = purchases["ClientID"][first]
later_visit = (
(weekday.columns["ClientID"] == cookie)
& (weekday.columns["VisitID"] != visit)
& (weekday.columns["UTCEventTime"] > purchases["UTCEventTime"][second])
)
if np.any(later_visit):
gap = (
(
weekday.columns["UTCEventTime"][later_visit].min()
- purchases["UTCEventTime"][second]
)
.astype("timedelta64[s]")
.astype(int)
)
assert gap > world.VISIT_TIMEOUT_SECONDS
def paired_cookies() -> set[int]: def paired_cookies() -> set[int]:
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём.""" """Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
cookies: set[int] = set() cookies: set[int] = set()
@@ -642,21 +735,26 @@ def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]):
«Постоянный покупатель» читается только так — как кука, которая ходит «Постоянный покупатель» читается только так — как кука, которая ходит
неделями и покупает не раз: метки в событии нет и не будет. неделями и покупает не раз: метки в событии нет и не будет.
Куки двухкуковых пар из счёта исключены: их заказы назначил план, и Куки двухкуковых пар из счета исключены: их заказы назначил план, и
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются вместе с ними лифт мерил бы гарантию, а не два новых рычага. Даже после
те помеченные, чьи покупки решил день: повторно покупают 4,6% из них событийной порчи оставшиеся помеченные покупают повторно более чем вдвое
против 1,5% прочих — втрое чаще при пороге «вдвое». чаще прочих.
""" """
purchases: dict[int, int] = {} purchases: dict[int, set[str]] = {}
flagged: set[int] = set() flagged: set[int] = set()
for number, events in enumerate(week): for number, events in enumerate(week):
audience = plan.audience(CANONICAL_SEED, number) audience = plan.audience(CANONICAL_SEED, number)
flagged |= set(audience.client_id[audience.buyer].tolist()) flagged |= set(audience.client_id[audience.buyer].tolist())
for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist(): rows = rows_of(events, commerce.PURCHASE)
purchases[cookie] = purchases.get(cookie, 0) + 1 for cookie, order_id in zip(
rows["ClientID"].tolist(),
(str(cell[0]) for cell in rows["purchaseID"]),
strict=True,
):
purchases.setdefault(cookie, set()).add(order_id)
def repeat_share(cookies: set[int]) -> float: def repeat_share(cookies: set[int]) -> float:
again = sum(1 for cookie in cookies if purchases[cookie] > 1) again = sum(1 for cookie in cookies if len(purchases[cookie]) > 1)
return again / len(cookies) return again / len(cookies)
buyers = set(purchases) buyers = set(purchases)
+3
View File
@@ -68,6 +68,7 @@ def orders(events: day.Day) -> list[list[Any]]:
int(theirs.outcome[number]), int(theirs.outcome[number]),
int(theirs.paid_after[number]), int(theirs.paid_after[number]),
int(theirs.cancelled_after[number]), int(theirs.cancelled_after[number]),
int(theirs.snapshot_delay[number]),
] ]
for number in range(len(theirs)) for number in range(len(theirs))
] ]
@@ -88,6 +89,7 @@ def snapshot(events: day.Day) -> dict[str, Any]:
"page": events.page.tolist(), "page": events.page.tolist(),
"product": events.product.tolist(), "product": events.product.tolist(),
"orders": orders(events), "orders": orders(events),
"purchase_outcome": events.purchase_outcome.tolist(),
} }
@@ -115,6 +117,7 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
page=weekday.page, page=weekday.page,
product=weekday.product, product=weekday.product,
orders=weekday.orders, orders=weekday.orders,
purchase_outcome=weekday.purchase_outcome,
) )
assert snapshot(reordered) != original assert snapshot(reordered) != original
+32
View File
@@ -0,0 +1,32 @@
"""Запускаемый путеводитель остается коротким и проходит всю цепочку."""
import subprocess
from pathlib import Path
def test_demo_runs_and_shows_the_whole_chain() -> None:
"""Сторожит команду и стадии, а числа мира оставляет описи."""
finished = subprocess.run(
["make", "--silent", "demo"],
check=False,
capture_output=True,
cwd=Path(__file__).parents[1],
text=True,
)
assert finished.returncode == 0, finished.stderr
lines = finished.stdout.splitlines()
assert [line.partition(":")[0] for line in lines] == [
"Зерно",
"Подпотоки D0",
"Когорта D0",
"Аудитория D0",
"Визиты по VisitID",
"Строки событий",
]
assert "когорты D-" in lines[3] and "...D0" in lines[3]
assert "строк в группе" in lines[4]
assert all(
label in lines[5]
for label in ("pageview", "add_to_cart", "purchase", "заказов")
)
+26 -1
View File
@@ -27,6 +27,9 @@ INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory
# отправляемых; этот дешевле прочих — его окно короче. # отправляемых; этот дешевле прочих — его окно короче.
RUN_DAY = 2 RUN_DAY = 2
# День любой: хеш дня не зависит ни от соседей, ни от края описи.
REPLAY_DAY = 4
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def inventory() -> dict: def inventory() -> dict:
@@ -48,7 +51,15 @@ def test_order_class_counters_cover_only_closed_days(inventory: dict):
rows = [row for row in inventory["days"] if "orders" in row] rows = [row for row in inventory["days"] if "orders" in row]
assert [row["day"] for row in rows] == [0] assert [row["day"] for row in rows] == [0]
assert set(rows[0]["orders"]) == {"match", "cancelled", "amount_delta"} assert set(rows[0]["orders"]) == {
"match",
"cancelled",
"lost_event",
"duplicate_event",
"amount_delta",
}
assert rows[0]["orders"]["lost_event"] > 0
assert rows[0]["orders"]["duplicate_event"] > 0
today = day_module.stream(CANONICAL_SEED, rows[0]["day"]) today = day_module.stream(CANONICAL_SEED, rows[0]["day"])
assert sum(rows[0]["orders"].values()) == len(today.orders) assert sum(rows[0]["orders"].values()) == len(today.orders)
@@ -72,3 +83,17 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm
row = next(row for row in inventory["snapshots"] if row["day"] == RUN_DAY - 1) row = next(row for row in inventory["snapshots"] if row["day"] == RUN_DAY - 1)
assert row["sha256"] == sent assert row["sha256"] == sent
def test_a_replayed_day_matches_its_inventory_hash(tmp_path):
"""Опись сверяется с байтами, вышедшими из CLI, а не с собранной тут же:
пересчет сравнивался бы сам с собой.
"""
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
path = tmp_path / "day.jsonl"
assert cli.main(["batch", "--day", str(REPLAY_DAY), "--file", str(path)]) == 0
sent = hashlib.sha256(path.read_bytes()).hexdigest()
row = next(row for row in stored["days"] if row["day"] == REPLAY_DAY)
assert row["sha256"] == sent
+63 -18
View File
@@ -58,17 +58,52 @@ def user_id_by_order(events: day.Day) -> dict[str, int]:
) )
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day): def purchase_row_by_order(
"""Заказ — вторая проекция покупки, а не второе порождение. events: day.Day,
) -> tuple[dict[str, NDArray[Any]], dict[str, int]]:
"""Первая строка purchase каждого доехавшего заказа."""
rows = purchases_of(events)
index: dict[str, int] = {}
for row, cell in enumerate(rows["purchaseID"]):
index.setdefault(str(cell[0]), row)
return rows, index
У всякого заказа ровно одно событие `purchase`, и номер у них один:
сойтись двум источникам больше негде — сверка стенда соединяется def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
именно по нему. """Заказ рождается до потери или дубля его событийной проекции."""
"""
events = purchases_of(weekday) events = purchases_of(weekday)
numbers = [cell[0] for cell in events["purchaseID"]] numbers = [cell[0] for cell in events["purchaseID"]]
assert len(numbers) > 100 assert len(numbers) > 100
assert list(weekday.orders.order_id) == numbers lost = {
order_id
for order_id, outcome in zip(
weekday.orders.order_id, weekday.purchase_outcome, strict=True
)
if outcome == commerce.EventOutcome.LOST
}
assert set(numbers) == set(weekday.orders.order_id) - lost
assert len(numbers) - len(set(numbers)) == int(
(weekday.purchase_outcome == commerce.EventOutcome.DUPLICATED).sum()
)
def test_an_assigned_order_never_waits_for_a_later_snapshot(
monkeypatch: pytest.MonkeyPatch,
):
"""Гарантия плана сильнее опоздания: мост склейки приезжает сразу."""
# Все неназначенные заказы опаздывают на два дня. Нулевую задержку после
# такого броска могут получить только заказы, защищённые планом.
monkeypatch.setattr(
orders, "_SNAPSHOT_DELAY_CUMULATIVE", np.array([0, 0, 1], dtype=np.int64)
)
events = day.stream(CANONICAL_SEED, WEEKDAY)
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
promised = set(audience.person_id[audience.assigned_order].tolist())
immediate = set(events.orders.user_id[events.orders.snapshot_delay == 0].tolist())
assert promised
assert immediate == promised
assert (events.orders.snapshot_delay == 2).any()
def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]): def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]):
@@ -88,16 +123,18 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
""" """
goods = catalog.catalog() goods = catalog.catalog()
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True)) price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
events = purchases_of(weekday) events, row_by_order = purchase_row_by_order(weekday)
money = weekday.orders money = weekday.orders
deltas = 0 deltas = 0
for number, order in enumerate(weekday.orders.order_id): for number, order in enumerate(weekday.orders.order_id):
assert order == events["purchaseID"][number][0] if weekday.purchase_outcome[number] == commerce.EventOutcome.LOST:
continue
row = row_by_order[order]
client = list( client = list(
zip( zip(
events["productID"][number].tolist(), events["productID"][row].tolist(),
events["productQuantity"][number].tolist(), events["productQuantity"][row].tolist(),
strict=True, strict=True,
) )
) )
@@ -110,7 +147,7 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
) )
# Выручка клиента и `items_total` бэкенда — одно число: в событии # Выручка клиента и `items_total` бэкенда — одно число: в событии
# оно дробное, у заказа целое в копейках. # оно дробное, у заказа целое в копейках.
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS) revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
if mine == client: if mine == client:
assert money.items_total[number] == revenue assert money.items_total[number] == revenue
continue continue
@@ -136,15 +173,18 @@ def test_the_discount_comes_from_the_coupon_of_the_event(
for today in start_world: for today in start_world:
money = today.orders money = today.orders
events = purchases_of(today) events, row_by_order = purchase_row_by_order(today)
codes = [cell[0] for cell in events["purchaseCoupon"]] for number, order_id in enumerate(money.order_id):
for number, code in enumerate(codes): if today.purchase_outcome[number] == commerce.EventOutcome.LOST:
continue
row = row_by_order[order_id]
code = events["purchaseCoupon"][row][0]
items_total = money.items_total[number] items_total = money.items_total[number]
expected = items_total * percent[code] // 100 if code else 0 expected = items_total * percent[code] // 100 if code else 0
assert money.discount[number] == expected, money.order_id[number] assert money.discount[number] == expected, money.order_id[number]
assert money.discount[number] < items_total assert money.discount[number] < items_total
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS) revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
discounted_delta += int(bool(code) and items_total != revenue) discounted_delta += int(bool(code) and items_total != revenue)
# Иначе проверка не отличила бы новую базу скидки от прежней. # Иначе проверка не отличила бы новую базу скидки от прежней.
@@ -255,8 +295,13 @@ def test_the_user_id_is_the_person_behind_the_cookie(weekday: day.Day):
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True) zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
) )
events = purchases_of(weekday) events = purchases_of(weekday)
for number, cookie in enumerate(events["ClientID"].tolist()): by_order = user_id_by_order(weekday)
assert weekday.orders.user_id[number] == person[cookie] for order_id, cookie in zip(
(cell[0] for cell in events["purchaseID"]),
events["ClientID"].tolist(),
strict=True,
):
assert by_order[order_id] == person[cookie]
assert np.all(weekday.orders.user_id > 0) assert np.all(weekday.orders.user_id > 0)
assert weekday.orders.user_id.max() < 2**53 assert weekday.orders.user_id.max() < 2**53
+12 -12
View File
@@ -14,8 +14,8 @@ import pytest
from clickstream_generator import plan, reference, world from clickstream_generator import plan, reference, world
from clickstream_generator.seeds import CANONICAL_SEED from clickstream_generator.seeds import CANONICAL_SEED
# Горизонт эталонного снимка — две недели (спека, раздел 5). # Окно замера плана — две недели: два цикла недельной сезонности.
SNAPSHOT_DAYS = 14 WINDOW_DAYS = 14
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -54,9 +54,9 @@ def test_the_plan_is_a_pure_function_of_the_seed():
def test_counters_are_a_pure_function_of_the_seed(): def test_counters_are_a_pure_function_of_the_seed():
first = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) first = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
plan.cohort.cache_clear() plan.cohort.cache_clear()
assert first == plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) assert first == plan.counters(CANONICAL_SEED, WINDOW_DAYS)
def test_a_remembered_cohort_cannot_be_spoiled_from_outside(): def test_a_remembered_cohort_cannot_be_spoiled_from_outside():
@@ -216,12 +216,12 @@ def test_the_passport_points_into_the_directories():
def test_the_daily_audience_matches_the_spec_band(): def test_the_daily_audience_matches_the_spec_band():
"""6–8 тыс. посетителей в день — правдоподобный средний магазин.""" """6–8 тыс. посетителей в день — правдоподобный средний магазин."""
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
assert all(6_000 <= size <= 8_000 for size in counters.audience) assert all(6_000 <= size <= 8_000 for size in counters.audience)
def test_the_influx_holds_its_daily_number(): def test_the_influx_holds_its_daily_number():
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
average = sum(counters.new_cookies) / len(counters.new_cookies) average = sum(counters.new_cookies) / len(counters.new_cookies)
assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10 assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10
@@ -232,7 +232,7 @@ def test_the_influx_breathes_with_the_week():
def mean(numbers: tuple[int, ...] | list[int]) -> float: def mean(numbers: tuple[int, ...] | list[int]) -> float:
return sum(numbers) / len(numbers) return sum(numbers) / len(numbers)
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
weekdays = [size for day, size in enumerate(counters.new_cookies) if day % 7 < 5] weekdays = [size for day, size in enumerate(counters.new_cookies) if day % 7 < 5]
weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5] weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5]
expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean( expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean(
@@ -250,7 +250,7 @@ def test_the_influx_differs_even_on_the_same_weekday():
def test_the_influx_is_visible_on_the_plan(): def test_the_influx_is_visible_on_the_plan():
"""Накопленная аудитория растёт с горизонтом и с дневной не сходится.""" """Накопленная аудитория растёт с горизонтом и с дневной не сходится."""
week = plan.counters(CANONICAL_SEED, 7) week = plan.counters(CANONICAL_SEED, 7)
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) fortnight = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
assert week.visitors < fortnight.visitors assert week.visitors < fortnight.visitors
assert fortnight.visitors > 2 * max(fortnight.audience) assert fortnight.visitors > 2 * max(fortnight.audience)
@@ -258,21 +258,21 @@ def test_the_influx_is_visible_on_the_plan():
def test_the_horizon_is_a_prefix_not_another_world(): def test_the_horizon_is_a_prefix_not_another_world():
"""Продление истории днём N+1 не трогает дни 1…N.""" """Продление истории днём N+1 не трогает дни 1…N."""
week = plan.counters(CANONICAL_SEED, 7) week = plan.counters(CANONICAL_SEED, 7)
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) fortnight = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
assert fortnight.audience[:7] == week.audience assert fortnight.audience[:7] == week.audience
assert fortnight.new_cookies[:7] == week.new_cookies assert fortnight.new_cookies[:7] == week.new_cookies
def test_counters_count_the_same_audience_that_the_day_gets(): def test_counters_count_the_same_audience_that_the_day_gets():
"""Счётчики и дня-функция спрашивают один план — расходиться им негде.""" """Счётчики и дня-функция спрашивают один план — расходиться им негде."""
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
for day in (0, 7, SNAPSHOT_DAYS - 1): for day in (0, 7, WINDOW_DAYS - 1):
actual = plan.audience(CANONICAL_SEED, day).client_id.size actual = plan.audience(CANONICAL_SEED, day).client_id.size
assert actual == counters.audience[day] assert actual == counters.audience[day]
def test_counters_know_the_pairs_before_a_single_event(): def test_counters_know_the_pairs_before_a_single_event():
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
assert counters.pairs > 0 assert counters.pairs > 0
+1 -1
View File
@@ -78,7 +78,7 @@ def test_day_components_do_not_share_randomness():
Component.TRAFFIC, Component.TRAFFIC,
Component.COMMERCE, Component.COMMERCE,
Component.ORDERS, Component.ORDERS,
Component.LATECOMERS, Component.BEACONS,
) )
assert len({int(component) for component in components}) == 4 assert len({int(component) for component in components}) == 4
draws = { draws = {
+64 -2
View File
@@ -128,7 +128,9 @@ def test_the_record_matches_the_wire_contract(days: list[day_module.Day]):
) )
if _born(record) == _date(SHORT_WINDOW_DAY): if _born(record) == _date(SHORT_WINDOW_DAY):
assert record["created_at"][:19] + "Z" == bought[record["order_id"]] # Потерянного purchase в трекере нет, но заказ остается.
if record["order_id"] in bought:
assert record["created_at"][:19] + "Z" == bought[record["order_id"]]
def test_negative_money_does_not_leave_the_source(days: list[day_module.Day]): def test_negative_money_does_not_leave_the_source(days: list[day_module.Day]):
@@ -174,6 +176,63 @@ def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]):
assert len(set(numbers)) == len(numbers) assert len(set(numbers)) == len(numbers)
def test_lateness_changes_only_the_rows_present_in_a_snapshot(
days: list[day_module.Day],
):
"""Неопоздавшие строки остаются прежними байтами и в прежнем порядке."""
number = SHORT_WINDOW_DAY
window = [today.orders for today in days[: number + 1]]
eager = serialize.orders(
[
replace(rows, snapshot_delay=np.zeros_like(rows.snapshot_delay))
for rows in window
],
number,
)
actual = serialize.orders(window, number)
actual_ids = {json.loads(payload)["order_id"] for payload in actual}
expected = [
payload for payload in eager if json.loads(payload)["order_id"] in actual_ids
]
assert len(actual) < len(eager)
assert actual == expected
def test_a_two_day_late_order_first_arrives_in_its_lived_state(
days: list[day_module.Day],
):
"""Заказ δ = 2 пропускает D и D+1 и догоняет жизнь, не меняя `created_at`."""
honest = days[0].orders
target = 0
delay = np.zeros_like(honest.snapshot_delay)
delay[target] = 2
cancelled_after = honest.cancelled_after.copy()
cancelled_after[target] = 30 * 3600
late = replace(
honest,
snapshot_delay=delay,
cancelled_after=cancelled_after,
)
order_id = late.order_id[target]
for number in (0, 1):
assert order_id not in {
json.loads(payload)["order_id"]
for payload in serialize.orders([late], number)
}
arrived = {
record["order_id"]: record
for record in map(json.loads, serialize.orders([late], 2))
}[order_id]
created_at = np.datetime_as_string(
late.created_at[target], unit="ms", timezone="UTC"
)
assert arrived["status"] == "cancelled"
assert arrived["created_at"] == created_at
def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]): def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]):
"""Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`. """Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`.
@@ -325,7 +384,10 @@ def _purchase_times(today: day_module.Day) -> dict[str, str]:
times = np.datetime_as_string( times = np.datetime_as_string(
today.columns["UTCEventTime"][here], unit="s", timezone="UTC" today.columns["UTCEventTime"][here], unit="s", timezone="UTC"
) )
return dict(zip(numbers, times.tolist(), strict=True)) result: dict[str, str] = {}
for number, moment in zip(numbers, times.tolist(), strict=True):
result.setdefault(number, moment)
return result
def _born(record: dict) -> date: def _born(record: dict) -> date:
+5
View File
@@ -122,6 +122,11 @@ def test_the_trade_event_never_overtakes_the_next_page():
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS) assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
def test_the_duplicate_delay_stays_inside_a_visit_timeout():
assert 0 < min(world.DUPLICATE_DELAY_SECONDS)
assert max(world.DUPLICATE_DELAY_SECONDS) <= world.VISIT_TIMEOUT_SECONDS
def conversion(cart: int, checkout_of_cart: int) -> float: def conversion(cart: int, checkout_of_cart: int) -> float:
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки.""" """Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2 return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
+3 -1
View File
@@ -6,6 +6,8 @@ FROM grafana/grafana:13.1.1
USER root USER root
ENV GF_PATHS_PLUGINS=/usr/share/grafana/plugins \ ENV GF_PATHS_PLUGINS=/usr/share/grafana/plugins \
GF_PLUGINS_PREINSTALL_DISABLED=true GF_PLUGINS_PREINSTALL_DISABLED=true
ARG CLICKHOUSE_PLUGIN_VERSION=4.20.0
RUN grafana cli --pluginsDir "$GF_PATHS_PLUGINS" \ RUN grafana cli --pluginsDir "$GF_PATHS_PLUGINS" \
plugins install grafana-clickhouse-datasource 4.20.0 --pluginUrl "https://github.com/grafana/clickhouse-datasource/releases/download/v${CLICKHOUSE_PLUGIN_VERSION}/grafana-clickhouse-datasource-${CLICKHOUSE_PLUGIN_VERSION}.zip" \
plugins install grafana-clickhouse-datasource "$CLICKHOUSE_PLUGIN_VERSION"
USER grafana USER grafana
@@ -0,0 +1,31 @@
slice_name: Сверка purchase и заказов
description: null
certified_by: null
certification_details: null
viz_type: pivot_table_v2
params:
viz_type: pivot_table_v2
groupbyRows:
- order_day
groupbyColumns:
- mismatch_class
metrics:
- order_count
adhoc_filters:
- expressionType: SIMPLE
subject: order_day
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
aggregateFunction: Sum
metricsLayout: COLUMNS
rowOrder: key_a_to_z
colOrder: key_a_to_z
order_desc: true
row_limit: 10000
valueFormat: SMART_NUMBER
date_format: smart_date
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000102
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000002
@@ -0,0 +1,30 @@
slice_name: Выручка по дням и категориям
description: null
certified_by: null
certification_details: null
viz_type: echarts_timeseries_bar
params:
viz_type: echarts_timeseries_bar
x_axis: report_date
metrics:
- revenue
groupby:
- product_category
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
row_limit: 10000
stack: Stack
orientation: vertical
show_legend: true
rich_tooltip: true
show_value: false
y_axis_format: ',.2f'
x_axis_time_format: smart_date
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000101
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -0,0 +1,29 @@
slice_name: Посетители и известные пользователи
description: null
certified_by: null
certification_details: null
viz_type: echarts_timeseries_line
params:
viz_type: echarts_timeseries_line
x_axis: report_date
metrics:
- visitors
- known_users
groupby: []
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
row_limit: 10000
seriesType: echarts_timeseries_line
show_legend: true
rich_tooltip: true
show_value: false
y_axis_format: ',d'
x_axis_time_format: smart_date
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000103
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000003
@@ -0,0 +1,105 @@
dashboard_title: Бизнес-сюжеты
description: null
css: null
slug: business-stories
certified_by: null
certification_details: null
published: true
uuid: 19600000-0000-4000-8000-000000000201
position:
DASHBOARD_VERSION_KEY: v2
ROOT_ID:
id: ROOT_ID
type: ROOT
children:
- GRID_ID
GRID_ID:
id: GRID_ID
type: GRID
parents:
- ROOT_ID
children:
- ROW-revenue
- ROW-pvo
- ROW-traffic
ROW-revenue:
id: ROW-revenue
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-revenue
meta:
background: BACKGROUND_TRANSPARENT
CHART-revenue:
id: CHART-revenue
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-revenue
children: []
meta:
chartId: 1
height: 50
width: 12
sliceName: Выручка по дням и категориям
uuid: 19600000-0000-4000-8000-000000000101
ROW-pvo:
id: ROW-pvo
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-pvo
meta:
background: BACKGROUND_TRANSPARENT
CHART-pvo:
id: CHART-pvo
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-pvo
children: []
meta:
chartId: 2
height: 50
width: 12
sliceName: Сверка purchase и заказов
uuid: 19600000-0000-4000-8000-000000000102
ROW-traffic:
id: ROW-traffic
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-traffic
meta:
background: BACKGROUND_TRANSPARENT
CHART-traffic:
id: CHART-traffic
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-traffic
children: []
meta:
chartId: 3
height: 50
width: 12
sliceName: Посетители и известные пользователи
uuid: 19600000-0000-4000-8000-000000000103
metadata:
color_scheme: supersetColors
cross_filters_enabled: false
chart_configuration: {}
expanded_slices: {}
default_filters: '{}'
native_filter_configuration: []
theme_uuid: null
version: 1.0.0
@@ -0,0 +1,106 @@
table_name: daily_traffic_v
main_dttm_col: report_date
currency_code_column: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
catalog: null
schema: dm
sql: null
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
always_filter_main_dttm: false
folders: null
uuid: 19600000-0000-4000-8000-000000000003
metrics:
- metric_name: known_users
verbose_name: Известные пользователи
metric_type: null
expression: SUM(known_users)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
- metric_name: visitors
verbose_name: Посетители
metric_type: null
expression: SUM(visitors)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
columns:
- column_name: _load_ts
verbose_name: Время загрузки
is_dttm: true
is_active: true
type: DATETIME64(3, 'UTC')
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: known_users
verbose_name: Известные пользователи
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_id
verbose_name: Запуск загрузки
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: visitors
verbose_name: Посетители
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: report_date
verbose_name: Дата
is_dttm: true
is_active: true
type: DATE
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
version: 1.0.0
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
@@ -0,0 +1,136 @@
table_name: purchase_vs_orders_v
main_dttm_col: order_day
currency_code_column: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
catalog: null
schema: dm
sql: null
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
always_filter_main_dttm: false
folders: null
uuid: 19600000-0000-4000-8000-000000000002
metrics:
- metric_name: order_count
verbose_name: Количество заказов
metric_type: null
expression: COUNT(order_id)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
columns:
- column_name: items_total
verbose_name: Сумма позиций заказа
is_dttm: false
is_active: true
type: NULLABLE(DECIMAL(18, 2))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_ts
verbose_name: Время загрузки
is_dttm: true
is_active: true
type: DATETIME64(3, 'UTC')
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: declared_revenue
verbose_name: Выручка purchase
is_dttm: false
is_active: true
type: NULLABLE(DECIMAL(18, 2))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: status
verbose_name: Статус заказа
is_dttm: false
is_active: true
type: LOWCARDINALITY(NULLABLE(STRING))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: mismatch_class
verbose_name: Класс расхождения
is_dttm: false
is_active: true
type: LOWCARDINALITY(STRING)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: order_day
verbose_name: День заказа
is_dttm: true
is_active: true
type: DATE
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_id
verbose_name: Запуск загрузки
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: order_id
verbose_name: Заказ
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
version: 1.0.0
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
@@ -0,0 +1,138 @@
table_name: revenue_daily_v
main_dttm_col: report_date
currency_code_column: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
catalog: null
schema: dm
sql: null
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
always_filter_main_dttm: false
folders: null
uuid: 19600000-0000-4000-8000-000000000001
metrics:
- metric_name: revenue
verbose_name: Выручка
metric_type: null
expression: SUM(revenue)
description: null
d3format: ',.2f'
currency:
symbol: ₽
symbolPosition: suffix
extra: null
warning_text: null
columns:
- column_name: _load_ts
verbose_name: Время загрузки
is_dttm: true
is_active: true
type: DATETIME64(3, 'UTC')
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: aov
verbose_name: Средний чек
is_dttm: false
is_active: true
type: DECIMAL(18, 2)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: revenue
verbose_name: Выручка
is_dttm: false
is_active: true
type: DECIMAL(18, 2)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: product_category
verbose_name: Категория товара
is_dttm: false
is_active: true
type: LOWCARDINALITY(STRING)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_id
verbose_name: Запуск загрузки
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: orders
verbose_name: Заказы
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: units
verbose_name: Товары
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: report_date
verbose_name: Дата
is_dttm: true
is_active: true
type: DATE
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
version: 1.0.0
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
-2
View File
@@ -1,3 +1 @@
version: 1.0.0 version: 1.0.0
type: Database
timestamp: '2026-07-30T00:00:00+00:00'
+51 -23
View File
@@ -237,35 +237,42 @@ check_airflow_probes() {
} }
check_superset() { check_superset() {
local clickhouse_password local access_token
local available_schemas
local clickhouse_databases
local config local config
local connection_count
local connection_details
local connection_id
local database_list
local login local login
local metadata_tables local metadata_tables
local password local password
local port local port
local metadata_engine local schemas
local stored_config local stored_safe_uri
local stored_uri
local stored_uuid local stored_uuid
local superset_password local superset_password
local superset_user local superset_user
local superset_url
local user local user
config="$(compose config --format json 2>/dev/null || true)" config="$(compose config --format json 2>/dev/null || true)"
user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // empty' <<<"$config")" user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // empty' <<<"$config")"
password="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_PASSWORD // empty' <<<"$config")" password="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_PASSWORD // empty' <<<"$config")"
clickhouse_password="$(jq -r '.services.superset.environment.CLICKHOUSE_BI_PASSWORD // empty' <<<"$config")"
superset_user="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_USER // empty' <<<"$config")" superset_user="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_USER // empty' <<<"$config")"
superset_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")" superset_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")"
port="$(published_port superset 8088)" port="$(published_port superset 8088)"
superset_url="http://127.0.0.1:${port}"
login="$(curl -sf --max-time 10 -X POST \ login="$(curl -sf --max-time 10 -X POST \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "$(jq -cn --arg username "$user" --arg password "$password" \ -d "$(jq -cn --arg username "$user" --arg password "$password" \
'{username: $username, password: $password, provider: "db", refresh: true}')" \ '{username: $username, password: $password, provider: "db", refresh: true}')" \
"http://127.0.0.1:${port}/api/v1/security/login" 2>/dev/null || true)" "${superset_url}/api/v1/security/login" 2>/dev/null || true)"
if curl -sf --max-time 10 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && \ access_token="$(jq -r '.access_token // empty' <<<"$login")"
jq -e '.access_token | length > 0' >/dev/null 2>&1 <<<"$login"; then if curl -sf --max-time 10 "${superset_url}/health" >/dev/null 2>&1 && \
[[ -n "$access_token" ]]; then
pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора' pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора'
else else
fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора' fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора'
@@ -276,28 +283,49 @@ check_superset() {
psql -U "$superset_user" -d superset -tAc \ psql -U "$superset_user" -d superset -tAc \
"SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \ "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \
2>/dev/null || true)" 2>/dev/null || true)"
stored_config="$(timeout 30s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
exec -T superset python -c \ # Superset уже собрал приложение. Его REST API читает ту же сохранённую
"from superset.app import create_app; app = create_app(); app.app_context().push(); from superset.extensions import db; from superset.models.core import Database; url = db.engine.url; database = db.session.query(Database).filter_by(database_name='ClickHouse').one(); print('SMOKE_METADATA=' + '|'.join([url.get_backend_name(), url.username or '', url.host or '', str(url.port or ''), url.database or ''])); print('SMOKE_UUID=' + str(database.uuid)); print('SMOKE_URI=' + database.sqlalchemy_uri_decrypted)" \ # модель Database без второго тяжёлого запуска Python.
2>/dev/null)" database_list="$(curl -sf --max-time 10 \
metadata_engine="$(sed -n 's/^SMOKE_METADATA=//p' <<<"$stored_config" | tail -n 1)" -H "Authorization: Bearer ${access_token}" \
stored_uuid="$(sed -n 's/^SMOKE_UUID=//p' <<<"$stored_config" | tail -n 1)" "${superset_url}/api/v1/database/" 2>/dev/null || true)"
stored_uri="$(sed -n 's/^SMOKE_URI=//p' <<<"$stored_config" | tail -n 1)" clickhouse_databases="$(jq -c \
'[.result[]? | select(.database_name == "ClickHouse")]' \
<<<"$database_list")"
connection_count="$(jq -r 'length' <<<"$clickhouse_databases")"
connection_id="$(jq -r 'if length == 1 then .[0].id else empty end' \
<<<"$clickhouse_databases")"
stored_uuid="$(jq -r 'if length == 1 then .[0].uuid else empty end' \
<<<"$clickhouse_databases")"
connection_details=''
if [[ -n "$connection_id" ]]; then
connection_details="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${access_token}" \
"${superset_url}/api/v1/database/${connection_id}/connection" \
2>/dev/null || true)"
fi
stored_safe_uri="$(jq -r '.result.sqlalchemy_uri // empty' \
<<<"$connection_details")"
if [[ "$metadata_tables" == '1' ]] && \ if [[ "$metadata_tables" == '1' ]] && \
[[ "$metadata_engine" == "postgresql|${superset_user}|postgres-metadata|5432|superset" ]] && \
[[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \ [[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \
[[ "$stored_uri" == "clickhousedb://bi:${clickhouse_password}@clickhouse-02:8123/default" ]]; then [[ "$stored_safe_uri" == 'clickhousedb://bi@clickhouse-02:8123/default' ]]; then
pass 'метаданные Superset живут в Postgres, подготовленное подключение указывает на clickhouse-02' pass 'Postgres для метаданных Superset подготовлен, подключение указывает на clickhouse-02'
else else
fail "Superset не подтвердил Postgres и подготовленное подключение bi к ноде 2: таблицы=${metadata_tables:-нет}, движок=${metadata_engine:-нет}, UUID=${stored_uuid:-нет}" fail "Postgres или подключение Superset не подготовлены: таблицы=${metadata_tables:-нет}, подключений=${connection_count:-нет}, UUID=${stored_uuid:-нет}, URI=${stored_safe_uri:-нет}"
return return
fi fi
if printf 'n\n' | timeout 60s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ schemas="$(curl -sf --max-time 10 \
exec -T superset superset test-db "$stored_uri" >/dev/null 2>&1; then -H "Authorization: Bearer ${access_token}" \
pass 'Superset успешно проверил извлечённое из метаданных подключение ClickHouse' "${superset_url}/api/v1/database/${connection_id}/schemas/" \
2>/dev/null || true)"
available_schemas="$(jq -r '[.result[]?] | join(", ")' <<<"$schemas")"
if jq -e '.result | index("dm") != null' >/dev/null 2>&1 <<<"$schemas"; then
pass 'сохранённое подключение Superset видит схему витрин dm'
else else
fail 'Superset не смог проверить извлечённое из метаданных подключение ClickHouse' fail "сохранённое подключение Superset не видит схему витрин dm: схемы=${available_schemas:-нет}"
fi fi
} }
+3
View File
@@ -17,6 +17,9 @@ CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster;
-- База DDS заводится здесь же по той же причине, что и ODS. -- База DDS заводится здесь же по той же причине, что и ODS.
CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster; CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster;
-- База витрин завершает цепочку STG → ODS → DDS → DM.
CREATE DATABASE IF NOT EXISTS dm ON CLUSTER clickstream_cluster;
-- Справочники стоят вне цепочки STG → ODS → DDS → DM: в хранилище их никто не -- Справочники стоят вне цепочки STG → ODS → DDS → DM: в хранилище их никто не
-- производит, а читают их несколько слоёв — ADR 0012. Порядок слоёв к зоне не -- производит, а читают их несколько слоёв — ADR 0012. Порядок слоёв к зоне не
-- применяется, её файл идёт сразу за этим. -- применяется, её файл идёт сразу за этим.
+13 -11
View File
@@ -83,7 +83,7 @@ CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
GoalsReached Array(UInt32), GoalsReached Array(UInt32),
ParsedParamsKey1 Array(String), ParsedParamsKey1 Array(String),
purchaseID Array(String), purchaseID Array(String),
purchaseRevenue Array(Float64), purchaseRevenue Array(Decimal(18, 2)),
purchaseCurrency Array(String), purchaseCurrency Array(String),
purchaseCoupon Array(String), purchaseCoupon Array(String),
productID Array(String), productID Array(String),
@@ -156,17 +156,19 @@ ENGINE = Distributed('clickstream_cluster', 'ods', 'event_errors_rep', cityHash6
-- Слепок привозит состояние заказов окна изменяемости, а не поток изменений, -- Слепок привозит состояние заказов окна изменяемости, а не поток изменений,
-- и одна и та же сущность приезжает в нём много дней подряд. Поэтому строка -- и одна и та же сущность приезжает в нём много дней подряд. Поэтому строка
-- здесь — версия заказа, а не запись слепка: ключ сущности order_id, колонка -- здесь — версия заказа, а не запись слепка: ключ сущности order_id, колонка
-- версии updated_at (время последнего изменения строки в источнике). Порядок -- версии snapshot_date — день, состояние которого снял источник. У полного
-- версий решает источник, а не хранилище. -- слепка версия и есть слепок: истинно то, что источник сказал последним
-- (ADR 0014).
-- --
-- Четыре координаты отвечают на разные вопросы, и путать их нельзя: -- Четыре координаты отвечают на разные вопросы, и путать их нельзя:
-- updated_at — какая бизнес-версия новее; snapshot_date — в слепке какого -- snapshot_date — в слепке какого модельного дня источник показал строку;
-- модельного дня источник показал строку; _load_id — какой запуск Airflow её -- updated_at — когда строку последний раз меняли в источнике; _load_id —
-- принял; _load_ts — когда она приехала в хранилище. Ключом сущности не -- какой запуск Airflow её принял; _load_ts — когда она приехала в хранилище.
-- становится ни одна из трёх последних: они про наблюдение и загрузку. Версию -- Ключом сущности не становится ни одна: заказ один, а дней наблюдения и
-- выбирает только updated_at, поэтому у выжившей после схлопывания строки -- версий у него много. Схлопывание оставляет строку позднейшего слепка,
-- snapshot_date — один из дней, когда источник показывал эту версию, не -- поэтому snapshot_date выжившей — последний день, когда источник показывал
-- обязательно последний (docs/architecture/orders/ingestion.md). -- заказ, и по нему DDS читает объём пересборки
-- (docs/architecture/orders/ingestion.md).
-- --
-- PARTITION BY toDate(created_at) — по дню создания строки в источнике. Он -- PARTITION BY toDate(created_at) — по дню создания строки в источнике. Он
-- неизменен у всех версий заказа, поэтому версии лежат в одной партиции и -- неизменен у всех версий заказа, поэтому версии лежат в одной партиции и
@@ -194,7 +196,7 @@ CREATE TABLE IF NOT EXISTS ods.order_rep ON CLUSTER clickstream_cluster
_load_id String, _load_id String,
_load_ts DateTime64(3, 'UTC') _load_ts DateTime64(3, 'UTC')
) )
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', updated_at) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', snapshot_date)
PARTITION BY toDate(created_at) PARTITION BY toDate(created_at)
ORDER BY order_id; ORDER BY order_id;
+9 -4
View File
@@ -11,9 +11,10 @@ FROM ods.event_dist FINAL;
-- ODS: каноническое чтение текущего состояния заказов. -- ODS: каноническое чтение текущего состояния заказов.
-- --
-- То же, что у событий, но версию здесь задаёт источник: физическая таблица -- То же, что у событий, но версию здесь задаёт источник: физическая таблица
-- хранит все приехавшие версии заказа, а FINAL оставляет ту, у которой -- хранит все приехавшие версии заказа, а FINAL оставляет приехавшую
-- updated_at позже. Читать заказы голым SELECT по _dist значит зависеть от -- позднейшим слепком — колонка версии здесь snapshot_date. Читать заказы
-- того, сколько фоновых слияний успело пройти. -- голым SELECT по _dist значит зависеть от того, сколько фоновых слияний
-- успело пройти.
-- --
-- Поля остаются языком источника, обогащения нет: DDS читает это -- Поля остаются языком источника, обогащения нет: DDS читает это
-- представление и отдельно решает, какой будет его модель (ADR 0010). -- представление и отдельно решает, какой будет его модель (ADR 0010).
@@ -153,7 +154,11 @@ SELECT
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached, JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1, JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID, JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
JSONExtract(raw, 'purchaseRevenue', 'Array(Float64)') AS purchaseRevenue, -- Выгрузка несет JSON-числа типа Float64, но деньги хранятся в Decimal.
-- JSONExtract разбирает их сразу, без промежуточного двоичного числа:
-- синтаксис подтвержден документацией ClickHouse через Context7 и
-- запросом к стендовому ClickHouse 26.3 при исполнении #155.
JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue,
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency, JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon, JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
JSONExtract(raw, 'productID', 'Array(String)') AS productID, JSONExtract(raw, 'productID', 'Array(String)') AS productID,
+198
View File
@@ -0,0 +1,198 @@
-- DDS: физические модели слоя и доноры партиций.
--
-- Заказ и сессия гасят повтор заменой дневной партиции, без колонки версии.
-- Карта идентичностей пересчитывается целиком и пишет новую версию пары.
-- Локальная таблица заказа.
--
-- Зерно — заказ в текущем состоянии, ключ order_id. Историю версий слой не
-- хранит: у неё нет читателя ни в одной витрине, а ODS её и не обещает
-- (docs/architecture/dds/order.md, «Зерно»).
--
-- Партиция — order_date, день заказа в поясе счётчика. Партиция дневная не
-- ради размера куска, а потому что день — единица пересборки: пока заказ
-- внутри окна изменяемости, его день «дышит» и собирается заново, за окном
-- замерзает. Ключ партиции и есть контракт трансформации с хранилищем.
--
-- Ключ сортировки — order_id: единственный устойчивый ключ сущности, он же
-- ключ шардирования ниже. Колонки версии в движке нет: см. шапку файла.
CREATE TABLE IF NOT EXISTS dds.order_rep ON CLUSTER clickstream_cluster
(
order_id String,
user_id UInt64,
status LowCardinality(String),
-- День заказа посчитан один раз при загрузке, по образцу EventDate у
-- события: суточные срезы группируются по готовой дате и пояса не
-- упоминают (docs/architecture/storage.md, «Часовые пояса»).
order_date Date,
-- Времена лежат местными: DDS обслуживает человека с дашбордом, и пояс
-- пересчитан один раз здесь, а не в каждом отчёте. Линза названа в типе,
-- поэтому пояс сервера на эти колонки не влияет.
created_at DateTime64(3, 'Europe/Samara'),
updated_at DateTime64(3, 'Europe/Samara'),
items_total Decimal(18, 2),
discount Decimal(18, 2),
delivery Decimal(18, 2),
total Decimal(18, 2),
-- Позиции — три массива одной длины: артикул, количество, цена. Родная
-- для колоночного хранилища денормализация: позиция не живёт без заказа и
-- своего ключа не имеет, а разворачивает массивы читатель — ARRAY JOIN в
-- витрине выручки. Форма записи выбрана по дому: широкое событие хранит
-- повторяющиеся группы Метрики такими же параллельными колонками
-- (productID, productPrice, productQuantity в ods.event_rep), а не Nested.
item_sku Array(String),
item_quantity Array(UInt32),
item_price Array(Decimal(18, 2)),
-- Координаты собственной загрузки слоя: каким запуском и когда собран
-- день. При замене партиции они одинаковы у всех строк дня. Ярлыки ODS
-- сюда не переезжают — почему, разобрано в доке модели.
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY order_date
ORDER BY order_id;
-- Лицо загрузки и диагностики. Ключ шардирования — cityHash64(order_id), тот
-- же, что у ods.order_dist: заказ остаётся на одном шарде на всём пути через
-- слои, и соединения по зерну заказа обходятся без GLOBAL
-- (docs/architecture/storage.md, «Раскладка по шардам»).
CREATE TABLE IF NOT EXISTS dds.order_dist ON CLUSTER clickstream_cluster
AS dds.order_rep
ENGINE = Distributed('clickstream_cluster', 'dds', 'order_rep', cityHash64(order_id));
-- Донор партиций: таблица-двойник, в которой день собирается перед подменой.
--
-- Двойник нужен потому, что REPLACE PARTITION берёт готовый кусок из другой
-- таблицы, а не из результата запроса. Собрать день прямо в цель нельзя:
-- вставка добавила бы строки к уже лежащим, и повтор прогона удвоил бы день.
--
-- Форма двойника задана правилом донора: структура, ключ партиции, ключ
-- сортировки, первичный ключ и политика хранения обязаны совпадать с целью —
-- это требование самой операции (проверено по документации ClickHouse,
-- sql-reference/statements/alter/partition). Отсюда AS dds.order_rep вместо
-- повторённого списка колонок: расхождение становится невозможным по
-- построению и не держится на внимательности того, кто правит.
--
-- Своё имя в keeper двойник получает от макроса {table}, поэтому две таблицы
-- не делят путь репликации.
CREATE TABLE IF NOT EXISTS dds.order_stage_rep ON CLUSTER clickstream_cluster
AS dds.order_rep
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY order_date
ORDER BY order_id;
-- Двойник шардируется тем же ключом, что цель. REPLACE PARTITION идёт по
-- локальным таблицам, шард за шардом. Ляг строка заказа в
-- доноре на другой шард, чем её место в цели, — подмена перенесла бы её не
-- туда, и заказ пропал бы с одного шарда и задвоился на другом.
CREATE TABLE IF NOT EXISTS dds.order_stage_dist ON CLUSTER clickstream_cluster
AS dds.order_stage_rep
ENGINE = Distributed('clickstream_cluster', 'dds', 'order_stage_rep', cityHash64(order_id));
-- Локальная таблица сессии.
--
-- Зерно — визит: подряд идущие события одной куки без пауз длиннее тридцати
-- минут, не пересекающие границу суток (CONTEXT.md, «Визит»). Сущность
-- расчётная, в потоке её нет — слой режет и чеканит её сам
-- (docs/architecture/dds/session.md).
--
-- Партиция — день сессии. У заказа день дышит внутри окна изменяемости, у
-- сессии замерзает сразу: визит суток не пересекает, поэтому прожитый день
-- собирается один раз и целиком. Отсюда и цена отказа: пропущенный день
-- заказа вернуло бы в работу следующее окно, а день сессии вернёт только счёт
-- хвоста от первой дыры (sql/dds/session_scope.sql).
--
-- Ключ сортировки — кука и начало сессии: тем же порядком идёт и нарезка, и
-- чтение «визиты посетителя подряд». session_id в ключ не входит: он ручка
-- для витрин и разбора расхождений, а не адрес строки.
CREATE TABLE IF NOT EXISTS dds.session_rep ON CLUSTER clickstream_cluster
(
-- Суррогат слоя: cityHash64 от куки и начала сессии. Детерминированность
-- несущая — пересборка партиции обязана отчеканить те же id
-- (docs/architecture/dds/session.md, «Идентификатор»).
session_id UInt64,
client_id UInt64,
session_date Date,
started_at DateTime('Europe/Samara'),
finished_at DateTime('Europe/Samara'),
duration_seconds UInt32,
-- Счётчики по типам событий: у визита Метрики на этом месте достигнутые
-- цели, которых стенд не шлёт.
events_total UInt32,
pageviews UInt32,
cart_adds UInt32,
purchases UInt32,
-- Вход и выход визита, как StartURL и EndURL у Метрики. Полного списка
-- страниц нет и у неё: топы считает витрина по событиям.
entry_url String,
exit_url String,
-- Атрибуция визита — метки его первого события.
utm_source String,
utm_medium String,
utm_campaign String,
utm_content String,
utm_term String,
-- Паспорт куки: устройство и город приписаны ей на всю жизнь, поэтому
-- берутся с первого события и внутри визита не меняются.
device_category LowCardinality(String),
region_city String,
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY session_date
ORDER BY (client_id, started_at);
-- Лицо загрузки и диагностики. Ключ шардирования — cityHash64(client_id), то
-- же выражение, что у событий и у карты идентичностей
-- (docs/architecture/storage.md, «Раскладка по шардам»). Ко-локация здесь не
-- только про соединения: на ней стоит сама сборка — все события куки лежат на
-- одном шарде, и нарезать их можно, не собирая куку с двух нод
-- (sql/dds/session_rebuild.sql).
CREATE TABLE IF NOT EXISTS dds.session_dist ON CLUSTER clickstream_cluster
AS dds.session_rep
ENGINE = Distributed('clickstream_cluster', 'dds', 'session_rep', cityHash64(client_id));
-- Донор партиций сессии. Правило донора — в комментарии к dds.order_stage_rep
-- выше: структура и ключи обязаны совпадать с целью, отсюда AS dds.session_rep
-- и тот же ключ шардирования.
CREATE TABLE IF NOT EXISTS dds.session_stage_rep ON CLUSTER clickstream_cluster
AS dds.session_rep
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY session_date
ORDER BY (client_id, started_at);
CREATE TABLE IF NOT EXISTS dds.session_stage_dist ON CLUSTER clickstream_cluster
AS dds.session_stage_rep
ENGINE = Distributed('clickstream_cluster', 'dds', 'session_stage_rep', cityHash64(client_id));
-- DDS: известная связь куки трекера с пользователем магазина.
--
-- Полный запуск снова пишет все известные пары. Физические повторы до
-- фонового слияния допустимы: _load_ts выбирает последнюю версию пары.
-- Партиции нет, потому что карта пересчитывается целиком и её зерно не
-- связано с днём.
CREATE TABLE IF NOT EXISTS dds.identity_map_rep ON CLUSTER clickstream_cluster
(
client_id UInt64,
user_id UInt64,
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedReplacingMergeTree(
'/clickhouse/tables/{shard}/{database}/{table}',
'{replica}',
_load_ts
)
ORDER BY (client_id, user_id);
-- Карта лежит по ключу будущего соединения с событиями и сессиями.
CREATE TABLE IF NOT EXISTS dds.identity_map_dist ON CLUSTER clickstream_cluster
AS dds.identity_map_rep
ENGINE = Distributed(
'clickstream_cluster',
'dds',
'identity_map_rep',
cityHash64(client_id)
);
+86
View File
@@ -9,6 +9,9 @@
-- --
-- event_date берётся напрямую из EventDate. Пересчёт из event_time помешал бы -- event_date берётся напрямую из EventDate. Пересчёт из event_time помешал бы
-- первичному индексу отбирать день до чтения. -- первичному индексу отбирать день до чтения.
--
-- Europe/Samara задаёт пояс счётчика модельного мира. DDS отдаёт event_time
-- в этой временной линзе.
CREATE VIEW IF NOT EXISTS dds.event_v ON CLUSTER clickstream_cluster CREATE VIEW IF NOT EXISTS dds.event_v ON CLUSTER clickstream_cluster
AS AS
SELECT SELECT
@@ -62,3 +65,86 @@ SELECT
productQuantity AS product_quantity, productQuantity AS product_quantity,
productEventType AS product_event_type productEventType AS product_event_type
FROM ods.event_v; FROM ods.event_v;
-- DDS: публичный договор заказа на языке бизнес-модели.
--
-- В отличие от события, у заказа под представлением лежит физическая модель:
-- разбор позиций и пересчёт пояса стоят денег, и платить их при каждом чтении
-- значило бы собирать модель заново на лету (docs/architecture/dds/order.md,
-- «Отклонённые варианты»). Представление здесь ничего не вычисляет — оно
-- только называет колонки, которые слой обещает читателю.
--
-- FINAL нет и не будет: строка на заказ одна по построению, повтор гасит
-- замена партиции, а не колонка версии.
--
-- Служебные колонки перечислены наравне с деловыми — это координаты
-- собственной загрузки слоя, и расследование происшествия начинается с них:
-- по _load_id видно, какой запуск собрал день.
CREATE VIEW IF NOT EXISTS dds.order_v ON CLUSTER clickstream_cluster
AS
SELECT
order_id,
user_id,
status,
order_date,
created_at,
updated_at,
items_total,
discount,
delivery,
total,
item_sku,
item_quantity,
item_price,
_load_id,
_load_ts
FROM dds.order_dist;
-- DDS: публичный договор сессии на языке бизнес-модели.
--
-- Под представлением — физическая модель, как у заказа: оконная нарезка
-- визитов на каждое чтение означала бы считать сущность заново в каждом
-- отчёте (docs/architecture/dds/session.md, «Отклонённые варианты»).
--
-- Эталонного VisitID в договоре нет намеренно: наша нарезка обязана уметь
-- разойтись с эталоном, на этом стоит сверка. За эталоном она ходит в
-- ods.event_v.
CREATE VIEW IF NOT EXISTS dds.session_v ON CLUSTER clickstream_cluster
AS
SELECT
session_id,
client_id,
session_date,
started_at,
finished_at,
duration_seconds,
events_total,
pageviews,
cart_adds,
purchases,
entry_url,
exit_url,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
device_category,
region_city,
_load_id,
_load_ts
FROM dds.session_dist;
-- DDS: точная известная связь куки с пользователем магазина.
--
-- Полный пересчёт оставляет физические версии одной пары до слияния частей.
-- FINAL прячет это устройство от читателя; явный список колонок не даёт
-- служебным изменениям таблицы случайно расширить договор.
CREATE VIEW IF NOT EXISTS dds.identity_map_v ON CLUSTER clickstream_cluster
AS
SELECT
client_id,
user_id,
_load_id,
_load_ts
FROM dds.identity_map_dist FINAL;
+170
View File
@@ -0,0 +1,170 @@
-- DM: физические дневные витрины и доноры их партиций.
-- Выручка хранится в зерне «день заказа × категория товара». День служит
-- единицей пересборки, поэтому совпадает с ключом партиции.
CREATE TABLE IF NOT EXISTS dm.revenue_daily_rep ON CLUSTER clickstream_cluster
(
report_date Date,
product_category LowCardinality(String),
orders UInt64,
units UInt64,
revenue Decimal(18, 2),
aov Decimal(18, 2),
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY report_date
ORDER BY (report_date, product_category);
-- Категория — единственное измерение кроме дня. Шесть категорий стартового
-- мира близки по объему, поэтому простой ключ раскладывает строки достаточно
-- ровно и не добавляет в учебный пример составной хеш без читателя.
CREATE TABLE IF NOT EXISTS dm.revenue_daily_dist ON CLUSTER clickstream_cluster
AS dm.revenue_daily_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'revenue_daily_rep',
cityHash64(product_category)
);
-- Донор объявлен через AS и шардируется тем же ключом, что цель. REPLACE
-- PARTITION идет по локальным таблицам, поэтому структура и раскладка донора
-- обязаны совпадать с целью (docs/architecture/storage.md, «Раскладка по
-- шардам»).
CREATE TABLE IF NOT EXISTS dm.revenue_daily_stage_rep ON CLUSTER clickstream_cluster
AS dm.revenue_daily_rep
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY report_date
ORDER BY (report_date, product_category);
CREATE TABLE IF NOT EXISTS dm.revenue_daily_stage_dist ON CLUSTER clickstream_cluster
AS dm.revenue_daily_stage_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'revenue_daily_stage_rep',
cityHash64(product_category)
);
-- Сверка хранит одну строку на заказ. День — единица пересборки: пока рядом
-- живут два источника, строка может перейти из awaiting_order в обычный класс.
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_rep ON CLUSTER clickstream_cluster
(
order_day Date,
order_id String,
declared_revenue Nullable(Decimal(18, 2)),
items_total Nullable(Decimal(18, 2)),
status LowCardinality(Nullable(String)),
mismatch_class LowCardinality(String),
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY order_day
ORDER BY (order_day, order_id);
-- Ключ совпадает с DDS, поэтому строка заказа на всех слоях попадает на один
-- шард.
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_dist ON CLUSTER clickstream_cluster
AS dm.purchase_vs_orders_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'purchase_vs_orders_rep',
cityHash64(order_id)
);
-- Донор повторяет структуру и раскладку цели по правилу, разобранному у
-- revenue_daily_stage_rep и в docs/architecture/storage.md.
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_stage_rep ON CLUSTER clickstream_cluster
AS dm.purchase_vs_orders_rep
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY order_day
ORDER BY (order_day, order_id);
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_stage_dist ON CLUSTER clickstream_cluster
AS dm.purchase_vs_orders_stage_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'purchase_vs_orders_stage_rep',
cityHash64(order_id)
);
-- Трафик пересчитывается целиком: новая связь куки с пользователем меняет
-- прошлые дни. Версия запуска позволяет точному представлению скрыть строки
-- прежних пересчетов без TRUNCATE и без партиций.
CREATE TABLE IF NOT EXISTS dm.daily_traffic_rep ON CLUSTER clickstream_cluster
(
report_date Date,
visitors UInt64,
known_users UInt64,
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedReplacingMergeTree(
'/clickhouse/tables/{shard}/{database}/{table}',
'{replica}',
_load_ts
)
ORDER BY report_date;
-- Все версии одного дня должны попадать на один шард, иначе FINAL не сможет
-- выбрать последнюю через границу шардов.
CREATE TABLE IF NOT EXISTS dm.daily_traffic_dist ON CLUSTER clickstream_cluster
AS dm.daily_traffic_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'daily_traffic_rep',
cityHash64(report_date)
);
-- Сводка качества хранит текущее состояние атомарной проверки одного дня.
-- История запусков остаётся в Airflow, поэтому версия строки не нужна:
-- следующий успешный прогон заменяет дневную партицию целиком.
CREATE TABLE IF NOT EXISTS dm.dq_summary_rep ON CLUSTER clickstream_cluster
(
data_date Date,
check_name LowCardinality(String),
reference_rows UInt64,
actual_rows UInt64,
failed_rows UInt64,
status LowCardinality(String),
_load_id String,
_load_ts DateTime64(3, 'UTC')
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY data_date
ORDER BY (data_date, check_name);
-- Все строки одной проверки лежат на одном шарде. Таблица мала, а ключ
-- check_name делает раскладку донора и цели одинаковой без нового измерения.
CREATE TABLE IF NOT EXISTS dm.dq_summary_dist ON CLUSTER clickstream_cluster
AS dm.dq_summary_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'dq_summary_rep',
cityHash64(check_name)
);
-- Источник и цель REPLACE PARTITION обязаны совпадать по структуре, ключам и
-- политике хранения. Требование повторно сверено по документации ClickHouse
-- через Context7 27 августа 2026 года; AS не даёт формам разойтись.
CREATE TABLE IF NOT EXISTS dm.dq_summary_stage_rep ON CLUSTER clickstream_cluster
AS dm.dq_summary_rep
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY data_date
ORDER BY (data_date, check_name);
CREATE TABLE IF NOT EXISTS dm.dq_summary_stage_dist ON CLUSTER clickstream_cluster
AS dm.dq_summary_stage_rep
ENGINE = Distributed(
'clickstream_cluster',
'dm',
'dq_summary_stage_rep',
cityHash64(check_name)
);
+59
View File
@@ -0,0 +1,59 @@
-- DM: публичные договоры стендовых витрин.
-- День и категория — зерно выручки. Служебные колонки показывают запуск,
-- который последним заменил дневную партицию.
CREATE VIEW IF NOT EXISTS dm.revenue_daily_v ON CLUSTER clickstream_cluster
AS
SELECT
report_date,
product_category,
orders,
units,
revenue,
aov,
_load_id,
_load_ts
FROM dm.revenue_daily_dist;
-- Непарные стороны остаются NULL. При join_use_nulls = 0 ClickHouse заполнил
-- бы их нулями и пустыми строками, скрыв отсутствие источника.
-- Служебные колонки называют запуск, который последним заменил день.
CREATE VIEW IF NOT EXISTS dm.purchase_vs_orders_v ON CLUSTER clickstream_cluster
AS
SELECT
order_day,
order_id,
declared_revenue,
items_total,
status,
mismatch_class,
_load_id,
_load_ts
FROM dm.purchase_vs_orders_dist;
-- Полный пересчет пишет новую версию каждого дня. FINAL оставляет текущие
-- счетчики и прячет повторы запусков от потребителя.
CREATE VIEW IF NOT EXISTS dm.daily_traffic_v ON CLUSTER clickstream_cluster
AS
SELECT
report_date,
visitors,
known_users,
_load_id,
_load_ts
FROM dm.daily_traffic_dist FINAL;
-- Сводка отвечает, какие атомарные договоры дня выполняются сейчас.
-- Явный список не пропускает в публичный договор будущие физические колонки.
CREATE VIEW IF NOT EXISTS dm.dq_summary_v ON CLUSTER clickstream_cluster
AS
SELECT
data_date,
check_name,
reference_rows,
actual_rows,
failed_rows,
status,
_load_id,
_load_ts
FROM dm.dq_summary_dist;
+46
View File
@@ -0,0 +1,46 @@
-- DDS: полный пересчёт известных пар куки с пользователем.
--
-- События разложены по client_id; заказы по order_id. Ключи соединения не
-- ко-локированы, поэтому правило слоя требует явный GLOBAL. С подзапросами с
-- обеих сторон ClickHouse 26.3 выполняет соединение один раз на инициаторе
-- (docs/research/2026-08-22-distributed-join.md).
INSERT INTO dds.identity_map_dist
(
client_id,
user_id,
_load_id,
_load_ts
)
SELECT
client_id,
user_id,
{load_id:String},
now64(3, 'UTC')
FROM
(
SELECT DISTINCT
purchase.client_id,
orders.user_id
FROM
(
SELECT
client_id,
-- По договору события purchase_id содержит ровно один номер
-- заказа: одно подтверждение оформляет один заказ (спека, раздел 4).
purchase_id[1] AS order_id
FROM dds.event_v
WHERE event_type = 'purchase'
) AS purchase
GLOBAL INNER JOIN
(
SELECT
order_id,
user_id
FROM dds.order_v
) AS orders ON purchase.order_id = orders.order_id
)
-- В этой форме ClickHouse 26.3 уже пишет через инициатор. Явный ноль фиксирует
-- раскладку по ключу цели, если форма запроса или план изменятся (капкан #137).
SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0;
+89
View File
@@ -0,0 +1,89 @@
-- DDS: сборка дней заказа в таблицу-двойник перед заменой партиций.
--
-- Двойник очищается целиком в начале сборки, а не в конце: собранное остаётся
-- лежать до следующего запуска, и разбор происшествия видит ровно то, чем
-- подменили день. Очистка обязана быть первой ещё и по делу — вставка поверх
-- прежних строк удвоила бы день в доноре, а REPLACE PARTITION перенёс бы
-- удвоение в цель.
--
-- Дни собираются одной вставкой, а не по дню за раз: источник читается один
-- раз на весь отрезок. Кто раскладывает строки по шардам — вставка через
-- Distributed, ключ cityHash64(order_id) — разобрано в DDL двойника.
--
-- Чистит двойник DROP PARTITION ALL, а не TRUNCATE: последнее — отдельное
-- право, роли etl оно не выдано, а снятие всех партиций
-- входит в ALTER, который у неё есть (ADR 0007). Замерено: TRUNCATE отвечает
-- ролью etl кодом 497, DROP PARTITION ALL проходит. Расширять права ради
-- операции, для которой уже есть разрешённая форма, значило бы платить
-- доступом за синоним.
ALTER TABLE dds.order_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
INSERT INTO dds.order_stage_dist
(
order_id,
user_id,
status,
order_date,
created_at,
updated_at,
items_total,
discount,
delivery,
total,
item_sku,
item_quantity,
item_price,
_load_id,
_load_ts
)
-- Позиции разбираются здесь один раз, на границе ODS → DDS. Цена на проводе —
-- строка с двумя знаками, и JSONExtract читает её прямо в Decimal: замерено на
-- живом слепке, промежуточный toDecimal64 не нужен.
--
-- Разбор по типу молчалив, и знать об этом надо заранее. Строгий приём ODS
-- проверил только, что items — массив, внутрь он не смотрел; здесь тоже никто
-- не смотрит. Замерено: qty вида "oops" даёт 0, пропущенная цена даёт 0,
-- скаляр вместо объекта выпадает из массива целиком. На каноническом слепке
-- генератора такого не бывает, и сумма «цена × количество» сходится с
-- items_total у всех заказов, но сходится она по свойству источника, а не
-- потому, что её здесь кто-то проверяет. Постоянное место такой проверки —
-- контур DQ этапа 4; пока она делается руками при приёмке.
WITH JSONExtract(
items, 'Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))'
) AS lines
SELECT
order_id,
user_id,
status,
-- День заказа и оба времени переезжают в пояс счётчика: DDS обслуживает
-- человека с дашбордом, и пояс называется здесь в последний раз.
toDate(toTimeZone(created_at, 'Europe/Samara')),
toTimeZone(created_at, 'Europe/Samara'),
toTimeZone(updated_at, 'Europe/Samara'),
items_total,
discount,
delivery,
total,
arrayMap(line -> line.sku, lines),
arrayMap(line -> line.qty, lines),
arrayMap(line -> line.price, lines),
-- Координаты запуска, собравшего день: у всех строк дня они одни.
{load_id:String},
now64(3, 'UTC')
FROM ods.order_v
WHERE toDate(toTimeZone(created_at, 'Europe/Samara'))
BETWEEN {first_day:Date} AND {last_day:Date}
-- Вторая граница не меняет отбор, а открывает источнику отсечение
-- партиций: ODS нарезан по toDate(created_at) в UTC, и условие на
-- вычисленный день заказа для этого ключа непрозрачно — сервер прочёл бы
-- все партиции. Сутки пояса счётчика ложатся ровно на две даты UTC,
-- поэтому день слева берётся с запасом.
AND toDate(created_at) BETWEEN {first_day:Date} - 1 AND {last_day:Date}
-- Тот же ноль, что в приёме заказов: заводская двойка исполнила бы вставку
-- локально на каждом шарде, минуя ключ шардирования цели, и строки унаследовали
-- бы раскладку источника. Ключи ODS и DDS здесь совпадают, и раскладка вышла бы
-- верной — но по совпадению, а не по контракту (docs/architecture/storage.md,
-- «Раскладка по шардам», капкан #137).
SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0;
+28
View File
@@ -0,0 +1,28 @@
-- DDS: подмена одного дня заказов собранной партицией двойника.
--
-- Это и есть идемпотентность слоя. Повтор прогона безопасен, а «дыхание»
-- статусов внутри окна выражается сменой всей партиции, а не правкой строк.
--
-- Замена идёт по локальным таблицам, шард за шардом: ON CLUSTER рассылает её
-- на обе ноды, а по Distributed то же самое не работает — операции с
-- партициями отвечают кодом 48 (docs/architecture/storage.md, «Что
-- проверено»). Правильность пошардовой замены держится на том, что двойник
-- шардирован тем же ключом, что цель, — см. sql/ddl/45-dds-tables.sql.
--
-- Отсюда граница обещания: атомарность здесь пошардовая. Внутри шарда подмена
-- мгновенна — читатель видит либо прежний день целиком, либо новый; но две
-- локальные замены независимы, и в зазоре между ними чтение через Distributed
-- соберёт ответ из нового дня одного шарда и прежнего дня другого. Зазор
-- короткий, а отказ одной из двух замен растягивает его до повторного прогона.
--
-- Пустая партиция двойника — законный случай, а не сбой: день, в котором
-- заказов нет, снимает день из цели, и слой перестаёт показывать то, чего в
-- источнике больше нет.
--
-- День подставляет Jinja, а не параметр ClickHouse: в ALTER … PARTITION
-- сервер параметров не подставляет — замерено, отвечает синтаксической
-- ошибкой. Общее правило репозитория «параметры передаются отдельно, а не
-- строкой» здесь не нарушено по существу: значение пришло из ответа самого
-- ClickHouse на запрос объёма, а не снаружи стенда.
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dds.order_stage_rep;
+63
View File
@@ -0,0 +1,63 @@
-- DDS: объём пересборки заказов — с какого дня по какой собирает этот запуск.
--
-- Параметров у дага нет: объём он выводит сам из состояния слоёв
-- (docs/architecture/etl.md, «Объём прогона»). Ответ — две даты, границы
-- непрерывного отрезка; список дней между ними разворачивает даг. Пустой
-- источник даёт пустой ответ, и даг на нём останавливается с внятной ошибкой
-- вместо зелёного прогона, собравшего ничего.
--
-- Файл кончается без точки с запятой: даг читает его целиком и шлёт одним
-- запросом, дописывая формат ответа, — точка с запятой сделала бы из этого
-- мультизапрос, который ClickHouse не принимает.
--
-- Объём складывается из двух частей. Первая — дышащее окно: дни, которые нёс
-- последний слепок, то есть те, чьё состояние источник ещё может изменить.
-- Вторая — хвост от первого дня, которого в слое нет: первая сборка застаёт
-- слой пустым и собирает весь стартовый мир, а после разгона мира пачкой слой
-- догоняет уехавший вперёд источник.
--
-- **Хвост считается от первого недостающего дня, а не от последнего
-- собранного.** Разница не косметическая: замены партиций идут отдельными
-- задачами и падают порознь, поэтому упавший день оставляет дыру, пока
-- соседние дни собираются дальше. Считай мы прогресс по максимуму собранного
-- дня, дыра позади окна не заросла бы никогда, а прогоны продолжали бы
-- зеленеть над щербатым слоем. Цена честного ответа — проход по дню заказа
-- в ODS.
--
-- Окно читается из ярлыка слепка, а не вычисляется вычитанием окна
-- изменяемости: у выжившей строки snapshot_date — последний слепок, который
-- нёс этот заказ (ADR 0014). Поэтому оба края окна знает сам источник, а
-- копии его константы на стороне стенда больше нет.
--
-- FINAL не нужен ни здесь, ни ниже: время рождения заказа у всех его версий
-- одно, а строки последнего слепка несут максимум колонки версии — мерж
-- оставляет как раз их.
WITH
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
(
SELECT min(toDate(toTimeZone(created_at, 'Europe/Samara')))
FROM ods.order_dist
WHERE snapshot_date = last_snapshot
) AS window_start,
(
SELECT max(toDate(toTimeZone(created_at, 'Europe/Samara')))
FROM ods.order_dist
WHERE snapshot_date = last_snapshot
) AS window_end,
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
-- бы дни источника с локальной частью слоя на каждом шарде — день, чьи
-- заказы легли на соседний шард, показался бы недостающим.
-- minOrNull отличает «дыр нет» от даты по умолчанию.
(
SELECT minOrNull(day)
FROM (
SELECT DISTINCT toDate(toTimeZone(created_at, 'Europe/Samara')) AS day
FROM ods.order_dist
)
WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist)
) AS first_missing_day
SELECT
-- Дыр нет — начинаем с окна; есть — с первой из них, если она старше окна.
least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
window_end AS last_day
WHERE (SELECT count() FROM ods.order_dist) > 0
+131
View File
@@ -0,0 +1,131 @@
-- DDS: нарезка событий на визиты и сборка дней в таблицу-двойник.
--
-- Двойник очищается целиком в начале, дни собираются одной вставкой на весь
-- отрезок — по правилу заказа (sql/dds/order_rebuild.sql).
--
-- Нарезка — центральный расчёт слоя, и стоит она на двух окнах подряд.
-- Первое спрашивает у каждого события, сколько прошло с предыдущего события
-- той же куки в тот же день, и ставит единицу там, где пауза длиннее
-- таймаута: это шов между визитами. Второе считает нарастающий итог этих
-- единиц — номер визита внутри дня куки. Дальше визит становится обычной
-- группировкой.
--
-- Границу суток окна не проверяют: она уже в разбиении PARTITION BY
-- (client_id, event_date), поэтому визит через полночь не склеится и при
-- короткой паузе. Так правило визита ложится в запрос целиком: одна кука,
-- один день, паузы не длиннее таймаута (CONTEXT.md, «Визит»).
--
-- Первое событие куки за день сравнивается само с собой — третий аргумент
-- lagInFrame. Паузы у него нет, шов ему не нужен, и номер первого визита
-- получается нулём: номера различают визиты внутри дня куки, а не считают их
-- от единицы.
--
-- Порядок в окнах полный — метка времени и event_id. Окна считаются двумя
-- разными сортировками (видно в EXPLAIN PIPELINE), и при равных метках они
-- вправе разойтись: шов достался бы одной строке, а нарастающий итог другой,
-- и сессия разъехалась бы надвое.
--
-- Тридцать минут написаны числом, а не приехали параметром: это правило
-- аналитики, а не константа мира. Генератор режет визиты тем же порогом;
-- разойдись эти два числа — увидит счётная сверка с VisitID
-- (docs/architecture/dds/session.md, «Сверка с эталоном»).
ALTER TABLE dds.session_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
INSERT INTO dds.session_stage_dist
(
session_id,
client_id,
session_date,
started_at,
finished_at,
duration_seconds,
events_total,
pageviews,
cart_adds,
purchases,
entry_url,
exit_url,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
device_category,
region_city,
_load_id,
_load_ts
)
WITH
seams AS (
SELECT
client_id,
event_date,
event_time,
event_id,
event_type,
url,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
device_category,
region_city,
event_time - lagInFrame(event_time, 1, event_time) OVER visit > 1800
AS opens_visit
FROM dds.event_v
WHERE event_date BETWEEN {first_day:Date} AND {last_day:Date}
WINDOW visit AS (
PARTITION BY client_id, event_date
ORDER BY event_time, event_id
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
)
),
numbered AS (
SELECT
*,
sum(opens_visit) OVER (
PARTITION BY client_id, event_date
ORDER BY event_time, event_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS visit_no
FROM seams
)
SELECT
-- Чекан визита: кука и его начало. Время входит в хеш секундой эпохи,
-- поэтому id держится за содержание сессии, а не за порядок сборки.
cityHash64(client_id, min(event_time)),
client_id,
event_date,
min(event_time),
max(event_time),
max(event_time) - min(event_time),
count(),
countIf(event_type = 'pageview'),
countIf(event_type = 'add_to_cart'),
countIf(event_type = 'purchase'),
argMin(url, event_time),
argMax(url, event_time),
-- Атрибуция и паспорт куки берутся с первого события визита. У паспорта
-- любое событие дало бы то же: устройство и город у куки постоянны.
argMin(utm_source, event_time),
argMin(utm_medium, event_time),
argMin(utm_campaign, event_time),
argMin(utm_content, event_time),
argMin(utm_term, event_time),
argMin(device_category, event_time),
argMin(region_city, event_time),
{load_id:String},
now64(3, 'UTC')
FROM numbered
GROUP BY client_id, event_date, visit_no
-- Капкана parallel_distributed_insert_select здесь нет, и это выигрыш
-- ко-локации: ключ шардирования у источника и цели один — cityHash64 от
-- куки, — поэтому визит ляжет туда же, где лежали его события, исполнись
-- вставка хоть на инициаторе, хоть локально на шардах. Заказу приходится
-- называть ноль вслух: совпадение ключей у него случайное
-- (sql/dds/order_rebuild.sql). Здесь выбор сделан обратный и он тоже
-- осознанный: настройку не называть, чтобы не притворяться, будто её значение
-- что-то решает.
SETTINGS distributed_foreground_insert = 1;
+11
View File
@@ -0,0 +1,11 @@
-- DDS: подмена одного дня сессий собранной партицией двойника.
--
-- Механика и доводы целиком — sql/dds/order_replace.sql: замена идёт по
-- локальным таблицам, шард за шардом, атомарность пошардовая, день
-- подставляет Jinja, потому что параметров ALTER … PARTITION не берёт.
--
-- Разница одна, и она про отказ. Несобранный день заказа вернуло бы в работу
-- следующее окно; сессиям возвращать нечем, и упавшая замена оставляет дыру
-- до тех пор, пока её не найдёт счёт хвоста (sql/dds/session_scope.sql).
ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dds.session_stage_rep;
+50
View File
@@ -0,0 +1,50 @@
-- DDS: объём пересборки сессий — какие дни собирает этот запуск.
--
-- Ответ — две даты, границы непрерывного отрезка; список дней между ними
-- разворачивает даг. Пустой ответ означает пустой источник. Пустой первый
-- день — что собирать нечего: все прожитые дни уже в слое, и прогон честно
-- пропускает сборку.
--
-- Файл кончается без точки с запятой: почему — sql/dds/order_scope.sql.
--
-- Правый край — последний прожитый день, и знает его только позиция мира.
-- События живой день от прожитого не отличают: и посреди дня D, и сразу после
-- его конца max(EventDate) = D. А разница несущая — визит, начатый в 23:50,
-- посреди дня ещё идёт, и собранная сессия оказалась бы обрезанной навсегда:
-- пересобирать замёрзший день слой не станет
-- (docs/architecture/dds/session.md, «Движок и наполнение»).
--
-- Позиция — номер первого несыгранного дня, счётчик прожитого. Своей копии
-- начала оси стенд из-за неё не заводит: начало он спрашивает у данных, и
-- дата дня N в них — константа, начало оси плюс N. Держится якорь на том, что
-- ранние дни из ods.event не исчезают: срок жизни назначен сырью и таблицам
-- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql).
--
-- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма
-- первый шаг дага дожидается хвоста потока и очереди доставки: иначе
-- недожеванный хвост заморозил бы недособранный день
-- (docs/architecture/etl.md, «Готовность источника»).
WITH
(SELECT min(EventDate) FROM ods.event_dist) AS first_source_day,
first_source_day + ({position:UInt16} - 1) AS last_lived_day,
-- Хвост считается от первой дыры, а не от последнего собранного дня:
-- замены партиций падают порознь, и счёт по максимуму собранного дня
-- дыру позади себя не заметил бы (доводы целиком — sql/dds/order_scope.sql).
-- У сессий это единственный способ дыру найти: дышащего окна, которое
-- пересобрало бы день заодно, здесь нет.
--
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
-- бы дни источника с локальной частью слоя на каждом шарде.
-- minOrNull отличает «дыр нет» от даты по умолчанию.
(
SELECT minOrNull(day)
FROM (SELECT DISTINCT EventDate AS day FROM ods.event_dist)
WHERE day GLOBAL NOT IN (SELECT session_date FROM dds.session_dist)
) AS first_missing_day
SELECT
-- Живой день в слое отсутствует всегда, и работой это не считается:
-- дырой он станет, только когда мир его проживёт.
if(first_missing_day <= last_lived_day, first_missing_day, NULL) AS first_day,
last_lived_day AS last_day
-- FINAL не нужен: повтор доставки не двигает ни край оси, ни набор дней.
WHERE (SELECT count() FROM ods.event_dist) > 0
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more