221 Commits
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
ddmitry b8a5a6171c Merge pull request 'feat(dds): добавлен публичный контракт события' (#161) from 150-dds-event-contract into main
Reviewed-on: #161
2026-08-26 00:45:37 +03:00
ddadmin 9ea361d199 feat(dds): добавлен публичный контракт события
Зачем:
- Показать слой DDS как договор данных, которому не нужна физическая копия.
- Сохранить простой учебный стенд без отдельного маршрута ради узких прав.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 03:33:55 -04:00
ddmitry ea39982bdd Merge pull request 'Подключить официальный провайдер ClickHouse к Airflow' (#126) from 124-airflow-clickhouse-provider into main
Reviewed-on: #126
2026-08-22 10:06:05 +03:00
ddadmin 20b0666d28 fix(airflow): добавлен повтор запуска DAG после индексации
- Зачем:
  - при холодном старте GET уже видел DAG, пока POST запуска ещё считал его устаревшим.
- Что:
  - точный стартовый ответ 404 повторяется до двадцати раз с паузой три секунды.
  - прочие ответы и сетевые ошибки завершают команду без риска двойного запуска.
  - граница повторов объяснена в коде и документации проверок.
- Проверка:
  - выполнены `bash -n scripts/run-dag.sh`, `make config-test` и изолированные сценарии ответов API.
  - выполнены холодный `make up`, `make lint` и `make smoke` (21/21).
2026-08-22 02:20:01 -04:00
ddadmin 8d82679bc1 feat(airflow): подключён официальный провайдер ClickHouse
- Зачем:
  - нужен единый штатный путь исполнения SQL и программных запросов ClickHouse из Airflow.
- Что:
  - добавлен официальный провайдер и подключения типа `clickhouse`.
  - SQL-файлы переданы `SQLExecuteQueryOperator`, а программные запросы — `ClickHouseHook`.
  - разбор заказов собран в одном файле, DDL разделён по задачам, документация и смоук обновлены.
- Проверка:
  - выполнены `make lint` и `make config-test`.
  - собран образ, выполнены `pip check`, `make rebuild-storage`, `make smoke` (21/21), `make check-clickhouse` (10/10) и `make check-services` (7/7).
2026-08-22 01:40:56 -04:00
ddmitry 587e392220 Merge pull request 'Передать жизненный цикл мира Airflow' (#125) from 119-world-lifecycle into main
Reviewed-on: #125
2026-08-21 23:55:50 +03:00
ddadmin bca1ffa379 fix(world): устранены расхождения после переноса на main
- Зачем:
  - свежий main добавил мониторинг и проявил устаревшие доводы, мёртвые настройки и слишком короткий тайм-аут DDL.
- Что:
  - документация приведена к текущему жизненному циклу мира и составу стенда;
  - удалены лишняя проверка и неиспользуемый YAML-якорь;
  - ожидание ClickHouse возвращено к 300 секундам, а отказы и параметры DockerOperator пояснены.
- Проверка:
  - `make config-test`;
  - `make lint`;
  - `make smoke` — 21/21.
2026-08-21 23:38:45 +03:00
ddadmin 7e6554507a docs(world): описан жизненный цикл прикладного мира
- Зачем:
  - границы владения Compose и Airflow должны быть видны менти из одного источника истины.
- Что:
  - добавлены спека и ADR жизненного цикла мира;
  - обновлены README, архитектурные справочники и прежние решения;
  - правило полной очистки записано как явное и проверяемое требование к агенту.
- Проверка:
  - документация вычитана через `ai-text-lint` и `writing-for-agents`;
  - выполнена литературная вычитка русского текста;
  - `git diff --check`.
2026-08-21 23:02:15 +03:00
ddadmin fc5244cc67 feat(world): жизненный цикл передан Airflow
- Зачем:
  - Compose должен поднимать инфраструктуру, а Airflow — владеть прикладным миром.
- Что:
  - добавлены даги создания и пересоздания мира с явными состояниями позиции;
  - цели make переведены на REST API Airflow, старые одноразовые службы удалены;
  - добавлена ограниченная учётная запись ClickHouse для прикладного DDL.
- Проверка:
  - `make lint`, `make config-test`, `bash -n scripts/run-dag.sh infra/airflow/init.sh`;
  - `make lint` и `make typecheck` в `generator/`;
  - холодный `make up`, повторный `make up`, `make rebuild-storage` и сценарий оборванной очистки;
  - `make smoke`, `make check-clickhouse`, `make check-services`.
2026-08-21 23:01:11 +03:00
ddmitry 0340b060f2 Merge pull request 'Мониторинг: дашборды «Кластер» и «Запросы»' (#123) from feat/116-cluster-queries-dashboards into main
Reviewed-on: #123
2026-08-21 22:34:22 +03:00
ddadmin 48bec014cf fix(monitoring): доводы приведены к замерам, пары нод больше не расходятся
- Зачем:
  - второй круг ревью нашёл, что текст спорит с собственной приёмкой: окно
    держалось доводом «слияния за минуту-две», а замер даёт двадцать секунд;
    довод панели ошибок опирался на отказ по памяти, которого ADR 0004 на этом
    стенде не обещает.
- Что:
  - «Части» сортируются по таблице: сортировка по числу частей разводила две
    ноды одной таблицы, как только между ними вставала чужая.
  - у «Слияний» столбик шириной в минуту, иначе редкие события выходили
    волосяной линией; из заголовка убран «слой» — фильтр отсекает журналы
    сервера, а не выбирает слои.
  - в cluster.md исправлены довод за окно, ложное «времени в system.parts нет»,
    счёт панелей без нод (их две, не одна) и разрешение, в котором снят замер
    процессора.
  - панель ошибок и её запись в ADR 0002 переписаны под проверенное: отказ на
    шарде воспроизведён принудительным пределом памяти.
  - из queries.md убран абзац про SELECT 1 — он объяснял пол удалённой панели.
- Проверка:
  - make config-test; make smoke 21/21; make check-clickhouse 10/10.
  - все десять панелей отвечают через /api/ds/query в окнах на полчаса, час и
    шесть часов; шаг столбика слияний проверен отдельным запросом.
2026-08-21 15:28:03 -04:00
ddadmin 42075ff9fb fix(monitoring): панели уточнены по ревью, приёмка снята под нагрузкой
- Зачем:
  - слияния в верхних строках показывали только журналы самого сервера, а
    предел в 15 строк рвал пару нод пополам: панели отвечали не на свой вопрос.
- Что:
  - «Слияния» стали столбиками по слоям: журналы сервера отделены, ряды ods и
    stg видны своими.
  - «Части» показывают все таблицы слоёв целиком, без предела и без обрезанных
    пар.
  - у «Запросов» убран «Поток запросов» — его пол составляли пробники
    состояния; у «Кто спрашивает» убрана лишняя колонка.
  - ряд «коробка» назван «отведено» — как в заголовке панели.
  - доводы дописаны в cluster.md и queries.md, отступления от ADR 0002
    записаны в сам ADR.
- Проверка:
  - make config-test; make smoke 21/21; make check-clickhouse 10/10.
  - все десять панелей отвечают через /api/ds/query в окнах на полчаса, час и
    шесть часов.
2026-08-21 15:11:21 -04:00
ddadmin 7ffb4606f1 feat(monitoring): собраны дашборды «Кластер» и «Запросы»
- Зачем:
  - ADR 0002 обещает три дашборда; собран был один, а симптом инфраструктуры
    как следствие решения человека показать было негде.
- Что:
  - cluster.json — семь панелей: процессор, память с пределом коробки, диски,
    части по таблицам, слияния, keeper, очередь распределённых DDL.
  - queries.json — четыре панели поверх system.query_log: тяжёлые запросы,
    поток запросов, аппетит по пользователям, ошибки.
  - системные таблицы читаются через clusterAllReplicas; очередь DDL — без
    обёртки, она живёт в keeper и у обеих нод одна.
  - устройство обоих дашбордов записано в docs/architecture/monitoring/,
    правило про пороги поднято из data.md в README набора.
- Проверка:
  - make config-test; make smoke 21/21; make check-clickhouse 10/10.
  - все одиннадцать панелей отвечают через /api/ds/query.
2026-08-21 14:52:58 -04:00
ddmitry 272660ff66 Merge pull request 'Мониторинг: справочник зоны — в набор документов' (#122) from feat/121-monitoring-docs into main
Reviewed-on: #122
2026-08-21 21:19:03 +03:00
ddadmin 06c77c5ed2 docs(monitoring): «скребёт» заменено, поправлены падеж и тяжёлые фразы
- Зачем:
  - «что скребёт Prometheus» — калька со scrape, по-русски звучит коряво;
    рядом нашлись ошибка падежа и пара фраз, которые читаются с запинкой.
- Что:
  - заголовок набора и раздел названы по-русски: «за чем следит» и «кого
    опрашивает Prometheus».
  - «Единицы общие — сообщений за интервал» исправлено на именительный падеж.
  - разбиты две перегруженные фразы: про место диагностики версий и про то,
    что читать на панели сходимости.
- Проверка:
  - make config-test.
2026-08-21 14:15:57 -04:00
ddadmin 28177cff92 docs(monitoring): вводный абзац не повторяет каталог дашбордов
- Зачем:
  - проход на вычитание: каталог дашбордов появился в этом же PR, и абзац
    «что здесь есть» стал называть «Данные» второй раз.
- Что:
  - вводный абзац отсылает к каталогу, а имя дашборда и состав панелей
    остались только в самом каталоге.
- Проверка:
  - make config-test.
2026-08-21 14:07:35 -04:00
ddadmin ee0306d19e docs(monitoring): убраны повтор заголовков, запрет и двойной довод
- Зачем:
  - таблица панелей дублировала заголовки из data.json и уже правилась
    дважды; заголовок раздела называл запрещённое действие вместо нужного;
    довод про отвергнутые способы счёта сходимости стоял в двух местах.
- Что:
  - в таблице панелей остались только источники, вопрос панели читается с её
    заголовка на экране.
  - раздел про чтецов Kafka назван по тому, что панели делают, опасность
    kafka_commit_on_select осталась внутри как причина.
  - из записи о приёмке убран пересказ довода, замер оставлен.
- Проверка:
  - make config-test; make lint.
2026-08-21 14:01:32 -04:00
ddadmin 61eb2680ff docs(monitoring): приёмка дашборда переехала к дашборду
- Зачем:
  - в общем разделе «Что проверено» лежали два разных прогона, пола и
    дашборда; следующие дашборды копили бы там осадок.
- Что:
  - запись о приёмке дашборда «Данные» перенесена в data.md, в README.md
    осталась приёмка самой зоны.
- Проверка:
  - make config-test.
2026-08-21 14:00:33 -04:00
ddadmin 91e281fd32 docs(monitoring): справочник зоны разрезан на набор документов
- Зачем:
  - половина справочника была про один дашборд «Данные», а этап приносит ещё
    два дашборда и правила оповещения: описание самой зоны утонуло бы.
- Что:
  - заведён набор docs/architecture/monitoring/ по ADR 0011: README.md — зона
    и каталог дашбордов, data.md — устройство дашборда «Данные».
  - обе входящие ссылки перенацелены: README.md репозитория и ADR 0002.
- Проверка:
  - make config-test; ссылки из README.md и ADR 0002 открываются.
2026-08-21 14:00:05 -04:00
ddmitry d43a9b348b Merge pull request 'Дашборд «Данные»: свежесть, поток, брак, сходимость' (#120) from feat/115-data-dashboard into main
Reviewed-on: #120
2026-08-21 20:52:52 +03:00
ddadmin f7123f2ad7 fix(monitoring): ряды панелей подписаны своими именами, а не буквой запроса
- Зачем:
  - на живом дашборде видно то, чего не показывает API: при нескольких
    запросах в панели Grafana ставит перед именем ряда его букву — «A события»,
    «B приземлилось в ClickHouse». Читателю панели это шум.
- Что:
  - свежесть и поток собраны одним запросом на панель: два столбца вместо двух
    запросов. У потока это заодно вернуло ряд заказов, который пропадал, когда
    в окне не было ни одного слепка.
  - у сходимости ряды из разных источников слить нельзя, поэтому ряду
    ClickHouse имя задано переопределением поля.
- Проверка:
  - make config-test; на живом стенде подписи прочитаны прямо со страницы
    дашборда: «события», «заказы», «произведено в Kafka», «приземлилось в
    ClickHouse»; все шесть панелей отвечают через /api/ds/query.
2026-08-21 13:28:22 -04:00
ddadmin e5c573bb25 fix(monitoring): панели уточнены по ревью, приёмка снята на стенде
- Зачем:
  - две линии ревью нашли, что колонки панели брака читаются как
    непересекающиеся кучи, «вовремя ли» из ADR 0002 не считается вовсе, а
    отказ от ряда «против вчерашнего» нигде не записан рядом с решением.
- Что:
  - в панели брака столбец назван «Из них в брак»: брак — часть приехавшего,
    а не соседняя куча.
  - панель слепка считает паузу после прошлого слепка — это и есть «вовремя
    ли» из ADR 0002; пустая пауза у самого раннего слепка держится
    обнуляемым типом.
  - отказ от ряда «против вчерашнего» записан в ADR 0002 и в monitoring.md с
    доводом: сдвиг на модельные сутки прописал бы ускорение мира в JSON.
  - в monitoring.md сказано, почему свежесть читает физическую _dist, а не
    ods.*_v, и почему панель слепка не знает окна; довод про сломанный JSON
    оставлен у кода, а не задвоен в доке.
  - README больше не утверждает, что панелей нет.
  - у разбора дашбордов убран флаг -e: он обещал утверждение, которого нет.
- Проверка:
  - make config-test (краснеет на нарочно сломанном JSON дашборда), make lint,
    make smoke 21/21, make check-clickhouse 10/10; все шесть панелей отвечают
    через /api/ds/query. Опыты приёмки и их следы — monitoring.md,
    «Что проверено».
2026-08-21 11:23:05 -04:00
ddadmin 180ef5b94f feat(monitoring): собран дашборд «Данные» из шести панелей
- Зачем:
  - главный дашборд ADR 0002 отвечает на вопрос «с потоком всё в порядке?»;
    три его вопроса — свежесть, брак и сходимость — у предшественника не
    заданы вовсе, и в этом весь смысл экрана.
- Что:
  - добавлен infra/grafana/provisioning/dashboards/data.json: свежесть, поток,
    брак, сходимость, отставание чтения из Kafka и дневной слепок заказов;
    порогов и цветовых вердиктов на панелях нет.
  - в monitoring.md записано устройство дашборда: почему слои у панелей разные
    и как считается сходимость — с доводом и двумя отвергнутыми способами.
  - make config-test разбирает JSON дашбордов тем же доводом, что и файлы
    дагов: сломанный файл Grafana молча оставляет прежнюю версию.
- Проверка:
  - make config-test; на живом стенде make smoke 21/21 и make check-clickhouse
    10/10; все шесть панелей отвечают через /api/ds/query.
2026-08-21 11:06:43 -04:00
ddmitry 2ccb8ff72c Merge pull request 'Пол мониторинга: источник ClickHouse в Grafana и экспортёр Kafka' (#118) from feat/114-monitoring-floor into main
Reviewed-on: #118
2026-08-21 17:34:02 +03:00
ddadmin c5dd9c2d34 feat(monitoring): подготовлена основа мониторинга Kafka и ClickHouse
- Зачем:
  - подготовлен пол мониторинга для SQL-панелей и метрик отставания Kafka.
- Что:
  - добавлены отдельная роль ClickHouse, источник Grafana и Kafka exporter.
  - образ Grafana собирается с закреплённым плагином вне тома данных.
  - Prometheus, смоук и документация обновлены под четыре цели.
- Проверка:
  - make config-test; make lint.
  - make smoke; make check-clickhouse; make check-services.
  - make -C generator lint typecheck test.
2026-08-21 09:56:56 -04:00
ddadmin 0bb07fd91d docs(spec): мониторинг перенесён из хвоста в параллельный трек
- Зачем:
  - дашборды нужны точками наблюдения на приёмке этапов 4-8, а зависимость
    у них формально только от этапа 1: «в хвосте» было выбором, не
    необходимостью.
- Что:
  - раздел 9: дашборды строятся параллельно этапам 4-8, состав не меняется.
  - назван хвост этапа: runbook keeper/DDL, system.text_log, правила
    оповещения, урок «посмотрели - сломали - вернули».
  - записано правило: панели порогов и цветовых вердиктов не несут, красное
    и зелёное живёт в проверках.
- Проверка:
  - тикеты этапа нарезаны по этому раскрою: #114-#117 под #11.
2026-08-21 08:06:10 -04:00
ddadmin 67313bf997 chore(main): объединены локальные и удалённые изменения
- Зачем:
  - локальные уточнения документации нужно опубликовать без потери слитых изменений #96 и #109.
- Что:
  - актуальный `origin/main` объединён с двумя локальными коммитами документации.
  - сохранены уточнения применения настроек ClickHouse и границ проектирования заказов.
- Проверка:
  - `make config-test`.
  - `make lint`.
2026-08-21 11:09:49 +03:00
ddmitry fd988babdf Merge pull request 'Упростить имена таблиц заказов в ODS' (#113) from feat/109-simplify-order-table-names into main
Reviewed-on: #113
2026-08-21 11:02:14 +03:00
ddadmin 2a94ee9bde refactor(ods): упрощены имена таблиц заказов
- Зачем:
  - ODS должен показывать сущность заказа, а не способ её доставки слепком.
- Что:
  - физические таблицы и представление переведены на имена `order_*`.
  - SQL загрузки, DAG и документация приведены к тому же словарю.
- Проверка:
  - `make config-test`, `make lint`.
  - `make clean && make up`, `make smoke`, `make check-clickhouse`, `make check-services`.
2026-08-21 10:49:02 +03:00
ddmitry a24dfc4510 Merge pull request 'feat(stand): добавлены стартовые слепки заказов' (#112) from feat/96-start-world-snapshots into main
Reviewed-on: #112
2026-08-21 10:47:45 +03:00
ddadmin ba4743ed52 feat(stand): добавлены стартовые слепки заказов
- Зачем:
  - свежий стенд должен показывать отставание ночной выгрузки заказов от кликстрима.
- Что:
  - после событий генератор отправляет семь слепков дней 0…6 в общий топик orders.
  - make up запускает orders_ingest, ограниченно ждёт его завершения и оставляет день 7 первому ходу мира.
  - README и карта проверок описывают второй источник и измеренную цену подъёма.
- Проверка:
  - make clean && make up; make smoke; make check-clickhouse; make check-services.
  - cd generator && make lint && make typecheck && make test.
2026-08-20 19:12:01 +03:00
ddadmin 9bb044e1ce docs(orders): убран преждевременный вопрос о _load_id
- Зачем:
  - состав полей DDS определяется вместе с моделью, поэтому отдельная задача заранее не нужна.
- Что:
  - удалена задача #85 из списка открытых решений по заказам.
  - убран устаревший указатель на неё из описания приёма.
- Проверка:
  - выполнен `git diff --cached --check`.
2026-08-20 15:57:37 +03:00
ddadmin 0dcc874419 docs(clickhouse): уточнено применение настроек нод
- Зачем:
  - перезапуск мог оставить прежнее окружение и старый inode файла, а документы обещали неподдерживаемое живое применение.
- Что:
  - в README записано пересоздание нод с ожиданием их здоровья.
  - ADR 0007 отделяет решение от процедуры и очищен от устаревших обещаний.
- Проверка:
  - make config-test.
  - git diff --cached --check.
2026-08-20 15:10:13 +03:00
ddmitry 664abf0160 Merge pull request 'Исправить отрицательный total после складской дельты' (#108) from fix/102-negative-order-total into main
Reviewed-on: #108
2026-08-20 13:17:36 +03:00
ddadmin 64bcd3d596 fix(generator): исправлен расчёт скидки после складской дельты
- Зачем:
  - слепок D7 содержал отрицательный итог заказа и попадал в брак ODS.
- Что:
  - скидка пересчитана от корзины после удаления отсутствующей позиции.
  - сериализатор отклоняет отрицательные деньги через ValueError.
  - добавлены проверки восьми стартовых дней и обновлена опись мира.
- Проверка:
  - make lint, make typecheck и make test в generator/.
  - живой world_next_day: 1694 строки приняты, таблица ошибок пуста.
2026-08-20 13:13:14 +03:00
ddmitry 7b89ccb529 Merge pull request 'feat(clickhouse): справочники вынесены в зону dic, словарь читает подложку' (#107) from feat/105-dictionary-home into main
Reviewed-on: #107
2026-08-20 12:26:39 +03:00
ddadminandClaude Opus 5 5cc0ede75a fix(clickhouse): починена проверка словаря, выметены хвосты переноса
Зачем: ревью в три линии нашло настоящий дефект. Девятая проверка кластера
звала dictGet('dds.products', …) — объекта после переноса не существует, и
make check-clickhouse падал. Промах вырос из недосчёта: упоминания dds.products
я искал с фильтром по расширениям, .sh туда не попал, и в ADR уехало «четыре
упоминания» вместо семи.

Что: проверка переведена на dic.products и сверяет цену, умножив её обратно на
сто, — так утверждается ещё и точность приведения к Decimal. Раздел 3
мастер-спеки и список пользователей README догнали перенос: оба описывали
отменённое устройство. В ADR 0012 добавлено условие пересмотра для дома — для
него его не было, хотя ради дома тикет и заводился; недосчёт записан в
«Что проверено» как урок. Разнобой обозначений сведён: карта таблиц в доке
хранилища перешла на имена баз строчными, таблица мастер-спеки — на заголовок
«Где», зону всюду зовут зоной, а не слоем. Термин «Справочник» переписан без
метафоры и уложен в формат глоссария. Вычтено лишнее: комментарии DDL
сократились вдвое, из ADR 0006 и 0007 убраны самооправдание и дублирующие
абзацы, отраслевой образец получил честную оговорку о непроверяемости.

Проверка: make config-test, make lint, make smoke и make check-clickhouse —
все зелёные, десять проверок кластера из десяти.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:10:03 +03:00
ddadminandClaude Opus 5 d77acfdf6c fix(docs): расхождение нод при обновлении словаря названо верно
Зачем: горячее ревью поймало ошибку в трёх местах сразу. Было записано, что
после правки каталога ноды расходятся до тридцати секунд — число взято из
ширины окна LIFETIME(MIN 60 MAX 90). Ширина тут ни при чём: каждая нода ждёт
правки от нуля до верхней границы окна, фазы у них независимы, поэтому
расходиться они могут почти на все полторы минуты.

Что: исправлены ADR 0012, опорная точка мастер-спеки и комментарий DDL. Заодно
уточнено, что LIFETIME(0) неатомарность не предотвращал, а сжимал до разброса
исполнения SYSTEM RELOAD DICTIONARY ON CLUSTER, и что витрина выручки пока
спроектирована, а не построена.

Проверка: рассуждением, замером не подтверждалось — прежнее число тоже было
выведено, а не измерено. Замер в ADR (116 секунд на окне MIN 60 MAX 120)
относится к другому утверждению: что словарь обновляется сам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:47:23 +03:00
ddadminandClaude Opus 5 b3e66034fc docs(storage): устройство справочников оставлено в одном месте
Зачем: каждое утверждение о словаре было записано трижды — в доке хранилища, в
комментариях DDL и в ADR 0012. Правка поведения стоила бы трёх согласованных
правок, а расхождение между копиями обнаружилось бы не сразу. Дока хранилища —
карта, а не пересказ реализации.

Что: раздел «Справочники» сведён к своему уровню — правило принадлежности зоны,
топология на кластере, единицы денег и указатели. Механика (движок, форма пути,
пользователь, окно обновления) осталась только в sql/ddl/05-dic-catalog.sql,
рядом с кодом, который она объясняет. Блок «Что проверено» отправлен к замерам
ADR 0012 вместо их пересказа — так же, как он уже поступает с ADR 0005.

Проверка: минус 30 строк; ссылок на убранный подзаголовок в репозитории нет,
три относительные ссылки нового текста разрешаются в существующие файлы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:42:26 +03:00
ddadminandClaude Opus 5 abc94ba406 feat(clickhouse): справочники вынесены в зону dic, словарь читает подложку
Зачем: место словаря в dds было унаследовано от первого объекта, а не решено.
Справочник читают несколько слоёв, а производит его в хранилище ни один —
прописка внутри одного из потребителей приписывала DDS владение, которого у
него нет.

Что: заведена база dic вне цепочки STG → ODS → DDS → DM, dds.products переехал
в dic.products. Под словарём появилась подложка dic.products_file на движке
File — намеренное усложнение ради урока: в бою источник словаря приезжает
процессом, а не лежит файлом у сервера. Источник объявлен формой query и
приводит цену из целых копеек каталога в Decimal(18, 2), как у денег бэкенда.
Обновление — окном LIFETIME(MIN 60 MAX 90) вместо ручной перезагрузки, ценой
заявленной неатомарности между нодами. У словаря свой беспарольный
пользователь dict с единственным правом на чтение dic. Суффикс _file добавлен
в конвенцию имён ADR 0006. Решение, отвергнутые варианты и условия пересмотра
— ADR 0012.

Проверка: make clean && make up на собранном заново стенде — зелено, make smoke
20 проверок и 0 ошибок, make lint чисто. Словарь LOADED со 180 строками, цена
Decimal(18, 2) и точна на копейках: 188990 → 1889.90. Пользователь bi читает
словарь dictGet-ом со второй ноды, analyst — подложку соединением, база dds
пуста. Минимальное право на движок замерено тремя пользователями: достаточно
GRANT FILE ON *.*, хотя отказ называет TABLE ENGINE ON File.

Closes #105

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:37:26 +03:00
ddmitry 862dde9fa4 Merge pull request 'feat(clickhouse): добавлен словарь товаров из каталога' (#104) from feat/95-product-dictionary into main
Reviewed-on: #104
2026-08-19 07:38:16 +03:00
ddadmin 13abc58d98 feat(clickhouse): добавлен словарь товаров из каталога
- Зачем:
  - витринам нужен общий с генератором каталог, а менти — пример штатного обновления словаря.
- Что:
  - каталог смонтирован read-only в обе ноды и создан как dds.products ON CLUSTER.
  - роли получили dictGet, а check-clickhouse сверяет ответ под etl с исходным CSV.
  - источник, раскладка и явная политика обновления записаны в документации.
- Проверка:
  - make up с чистыми томами; make lint; make config-test; make smoke; make check-clickhouse.
  - в generator: make lint typecheck test — 427 тестов прошли.
2026-08-19 00:50:14 +03:00
ddmitry 62b275ef94 Merge pull request 'feat(orders): добавлен версионный ODS заказов' (#103) from feat/94-versioned-order-ods into main
Reviewed-on: #103
2026-08-18 23:58:54 +03:00
ddadmin a5508bd9ec refactor(orders): SQL приёма вынесен из DAG
- Зачем:
  - преобразования хранилища должны читаться рядом с целевым слоем, а DAG должен показывать оркестрацию.
- Что:
  - запросы забора и разбора заказов разложены по каталогам STG и ODS.
  - общий контракт провода подключён в обе ветви штатным шаблонизатором Airflow.
  - SQL смонтирован во все службы Airflow, а правило раскладки записано в архитектуре.
- Проверка:
  - make lint config-test smoke check-services.
  - airflow tasks render для pull_batch и parse_batch; airflow tasks test для parse_batch.
2026-08-18 23:55:04 +03:00
ddadmin bdcbe48b59 feat(orders): добавлен версионный ODS заказов
- Зачем:
  - менти должен различать версию заказа, наблюдение источника и запуск загрузки.
- Что:
  - добавлены таблицы версий и брака заказов, а также представление ods.order_v.
  - даг orders_ingest дополнен строгим переходом одного среза STG в две цели ODS.
  - результаты опытов записаны в документации, дефект генератора вынесен в #102.
- Проверка:
  - make lint config-test smoke check-clickhouse check-services.
2026-08-18 23:10:01 +03:00
ddmitry 11a494f550 Merge pull request 'feat(orders): добавить приём слепков в STG' (#101) from feat/93-orders-ingestion into main
Reviewed-on: #101
2026-08-18 21:49:08 +03:00
ddadmin 6bd9190142 feat(orders): добавить приём слепков в STG
Зачем:
- связать проигрывание модельных дней с пакетным приёмом заказов
- показать на одном стенде различие потокового push и пакетного pull

Что:
- добавлен топик, Kafka-чтец и реплицированное сырьё заказов
- добавлен даг orders_ingest с одним прямым чтением и идентификатором загрузки
- работники мира отправляют слепок, ждут приём и только затем двигают позицию
- решения, границы отказа и проверки отражены в ADR и архитектурных документах

Проверка:
- make lint
- make config-test
- make smoke
- make check-clickhouse
- make check-services
2026-08-18 21:33:13 +03:00
ddmitry e67ee27a17 Merge pull request 'feat(generator): добавлена команда слепков заказов' (#100) from feat/92-order-snapshot into main
Reviewed-on: #100
2026-08-18 17:17:16 +03:00
ddadmin 8e21381e13 feat(generator): добавлена команда слепков заказов
- Зачем:
  - задача #92 добавляет второй источник и показывает слепок как чистую функцию зерна и дня.
- Что:
  - добавлены окно, состояние на границе суток и канонические байты с настоящими миллисекундами.
  - добавлены команда snapshot, сдвиг D → D−1 и переиспользование проигранных дней.
  - опись дополнена хешами слепков и поведенческими тестами.
- Проверка:
  - в generator выполнены make lint, make typecheck и make test: 427 тестов.
2026-08-18 17:12:49 +03:00
ddmitry c11da3b252 Merge pull request 'feat(generator): добавлена судьба заказов' (#99) from feat/91-order-fate into main
Reviewed-on: #99
2026-08-18 16:06:32 +03:00
ddadmin 0d90bd566b feat(generator): добавлена судьба заказов
- Зачем:
  - issue #91 требует решать исход и расхождение заказа в день его рождения.
- Что:
  - добавлены три исхода, моменты оплаты и отмены и дельта одной позиции.
  - обновлены проверки повторяемости и архитектурное описание заказов.
- Проверка:
  - cd generator && make lint && make typecheck && make test (418 passed).
2026-08-18 16:02:12 +03:00
ddmitry 62941d2ee5 Merge pull request 'feat(generator): заказы дня — проекция покупок с деньгами магазина' (#98) from feat/90-order-projection into main
Reviewed-on: #98
2026-08-18 15:19:14 +03:00
ddadminandClaude Opus 5 db632c2558 test(generator): анонимность события проверяется и текстом, не только числом
- Зачем:
  - холодное ревью Кодексом (линия дефектов) показало дыру в проверке
    анонимности: личность, уехавшая строкой в сыром `ecommerce`, проходила
    мимо сравнения числовых колонок, и тест оставался зелёным. Дыра
    воспроизведена: user_id, дописанный в блок покупки, тест не замечал.
- Что:
  - тест анонимности спрашивает двумя способами: числовые колонки дня — со
    всеми личностями аудитории, строки покупок целиком — с личностями
    покупателей. Сырой блок собирается руками, и лишнее поле в нём теперь
    красит проверку.
- Проверка:
  - из `generator/`: make lint, make typecheck, make test (417 тестов).
  - мутация «дописать user_id в ecommerce покупки»: до правки тест зелёный,
    после — красный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:46:16 +03:00
ddadminandClaude Opus 5 b2fcc7e593 feat(generator): заказы дня — проекция покупок с деньгами магазина
- Зачем:
  - тикет #90: второй источник должен смотреть на торговую половину дня
    глазами бэкенда — заказ есть проекция покупки, а не второе порождение;
    учебный результат — два источника согласованы по построению, а не
    сверкой.
- Что:
  - `commerce` отдаёт вторым выходом покупки дня: номер заказа, корзину,
    выручку клиента, промокод и человека за кукой; новых бросков в
    подпоток `COMMERCE` не добавилось, события дня не сдвинулись.
  - заведена заказная сторона: подпоток `ORDERS` на позиции 2 дерева зерна
    (прежнее мёртвое имя `DISCREPANCIES`), ветвление — по дню рождения
    заказа; `LATECOMERS` не тронут, его наполнит этап 6.
  - новый модуль `orders.py`: деньги заказа целыми копейками —
    `items_total` из корзины, `discount` по таблице «код → скидка»
    (округление вниз), `delivery` броском по таблице весов на полную длину
    дня, `total` = `items_total` − `discount` + `delivery`.
  - план состава завёл `person_id` — последним броском когорты, после
    паспортов: вторая кука пары повторяет ID своего человека, заказ
    показывает его как `user_id`, в событие Метрики он не попадает.
  - `world`: окно изменяемости `ORDER_WINDOW_DAYS` = 7 рядом с D0 и поясом,
    черновая таблица стоимости доставки.
  - слепок дня в тестах сторожит обе половины: заказы сравниваются наравне
    с потоком событий.
  - доки: имя подпотока заказной стороны и состав денег заказа записаны в
    `docs/architecture/orders`, README генератора знает про новый модуль.
- Проверка:
  - из `generator/`: make lint, make typecheck, make test (417 тестов);
    в корне — make lint.
  - опись мира не покраснела: байты восьми дней те же, заказная сторона
    события не сдвинула.

Closes #90

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:30:13 +03:00
ddmitry 716eafe865 Merge pull request 'Добавить каноническое чтение актуальных событий через ods.event_v' (#97) from feat/86-event-v into main
Reviewed-on: #97
2026-08-18 11:46:30 +03:00
ddadmin ded0dac5e2 feat(ods): добавлено представление актуальных событий
- Зачем:
  - потребителю ODS нужна точная актуальная версия события без знания о FINAL.
- Что:
  - создано ods.event_v над распределённой таблицей событий с FINAL.
  - сверка стартового мира переведена на новое представление.
  - документация различает физические версии ODS и бизнес-представление DDS.
- Проверка:
  - make config-test.
  - make check-clickhouse.
2026-08-18 11:33:59 +03:00
ddadminandClaude Fable 5 5f72e63b14 docs(specs): модель DDS заказа перенесена из этапа 3 в этап 4
Зачем: черновик этапов держал DDS заказа в этапе 3, а принятый набор
docs/architecture/orders/ оставляет модель DDS проектированию этапа 4
(ingestion.md «Не входит», тикет #85 перевешен на #6) — расхождение
всплыло на холодном ревью нарезки этапа (#89).

Что: в разделе 9 этап 3 сужен до STG/ODS и дага приёма, модель DDS
заказа названа работой этапа 4.

Проверка: чтение; нарезка этапа 3 (#90–#96) согласована с этой строкой.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:57:20 +03:00
ddadminandClaude Fable 5 cf1c6c4636 docs(orders): спека превращена в принятый живой набор architecture/orders
Зачем: датированная спека — событие истории, а устройство компонента — живой
документ; большое полотно плохо грузится и агентом, и человеком (ADR 0011).

Что: вычитание #84 слито с переустройством формы: набор
docs/architecture/orders/ — индекс README и семь файлов по частям устройства
(нарезка по правилу «семь плюс-минус два»); датированные файлы удалены,
ссылки перенацелены, AGENTS.md дополнен правилом подпапки. Приёмка
владельцем #88 пройдена, черновой статус снят из README.

Проверка: холодная сверка миграции свежим тредом — потерь решений нет;
обход относительных ссылок набора — битых нет.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:05:52 +03:00
ddadmin 3b4827d819 chore(git): каталог .scratch добавлен в игнор
Зачем: локальные рабочие файлы агентов (хэндоффы, обменные артефакты ревью)
живут в репозитории, чтобы переживать перезагрузку, но в git им не место.

Что: .scratch/ в .gitignore.

Проверка: git status не показывает .scratch/.
2026-08-17 22:05:52 +03:00
ddadminandClaude Fable 5 905ca5bbaf docs(orders): спека второго источника собрана из решений развилок
Зачем: решения развилок #70–#74, #80, #81 карты #69 разошлись по резолюциям
тикетов — цельная картина второго источника нужна в одном месте.

Что: черновик спеки заказов docs/specs/2026-08-16-orders.md и спека приёма
docs/specs/2026-08-16-order-ingestion.md; согласующие правки соседних
документов; правки горячего ревью и двух слепых холодных линий — дефекты
сборки закрыты, назван порядок строк внутри слепка.

Проверка: перепроверка находок теми же ревьюерами — ALL_CLOSED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:05:23 +03:00
ddadmin ede1df765c docs(orders): зафиксирован версионный приём заказов
- Зачем:
  - отменённая подмена партиции snapshot_date противоречила порционному чтению Kafka и могла обучать потере ранее принятых версий.
- Что:
  - добавлены спецификация приёма заказов и ADR о версионном ODS с ods.order_v.
  - согласованы мастер-спека, дока хранилища, ADR 0008 и исследование формата.
  - зафиксированы граница приёма, координаты загрузки, диагностические повторы и отложенное проектирование DDS.
- Проверка:
  - git diff --cached --check.
  - горячее ревью по правилам репозитория и принятому решению.
  - два прохода холодного ревью.
2026-08-16 21:42:11 +03:00
ddadmin 8d54a3caba docs(orders): уточнён формат слепка и временные поля
- Зачем:
  - аудит источника нельзя смешивать с бизнес-временем и `_load_ts` ClickHouse.
- Что:
  - зафиксированы JSON-контракт слепка, порядок ключей и намеренное различие `Array(Float64)` и `Decimal`.
  - разведены бизнес-время, аудит источника и загрузка; уточнены слой ODS, `snapshot_date` и технический ключ партиции.
  - добавлены термины словаря и исследование точного миллисекундного формата с проверками ClickHouse.
- Проверка:
  - выполнен `git diff --cached --check`.
2026-08-16 17:56:46 +03:00
ddadminandClaude Opus 5 c17c5ef995 docs(domain): опись мира — вернуть счёт событий и паспорт
Зачем: определение описи разошлось с файлом, который называет. Формула
«счётчики закрытых окон с названным читателем» не покрывает счёт событий по
дням, а он в описи лежит и сегодня, и читатель у него самый живой: make
check-clickhouse сверяет по нему приход в ods.event.

Что: счёт событий вернулся в определение строкой дня; «паспорт» снова
раскрыт скобкой — двумя термами выше стоит «Паспорт куки», и голое слово с
ним сталкивалось; вернулось «единственное, что о мире хранится в git» —
определяющее свойство описи, а не следствие. Строка про слепок и правило
«счётчик только с названным читателем» сохранены.

Проверка: определение сверено с data/world-inventory.json и с резолюцией
«Места заказов в стартовом мире» (#74).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:30:39 +03:00
ddadmin 86e2d6dbb2 docs(domain): уточнены слепки и опись мира
- Зачем:
  - документация не должна обещать постоянные числа для движущегося мира.
- Что:
  - уточнено появление опоздавших заказов в слепках.
  - опись ограничена неизменяемым выходом и счётчиками с читателем.
  - счётчик пар оставлен плану, но исключён из описи.
- Проверка:
  - CONTEXT.md перечитан целиком; git diff --check.
2026-08-16 12:26:10 +03:00
ddadminandClaude Opus 5 34267e8494 docs(spec): рамка — чем меряется генератор
Зачем: взгляд владельца на генератор жил только в разговорах, и каждый
следующий агент восстанавливал его заново или не восстанавливал вовсе.
Без него решения по генератору принимаются не по тому критерию: внутренняя
стройность вместо правдоподобия данных на выходе.

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

Рамка шире этапа 2, поэтому спеки следующих этапов ссылаются на неё, а не
копируют.

Проверка: правка только текстовая, кода не касается.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:09:11 +03:00
ddadmin c656ca198f docs(domain): уточнены роли человека и посетителя
- Зачем:
  - генератору нужен единый язык для скрытой личности и наблюдаемых идентификаторов.
- Что:
  - определены человек, посетитель и пользователь магазина.
  - закреплена граница между ClientID кликстрима и user_id заказа.
- Проверка:
  - документ перечитан целиком.
2026-08-16 11:56:57 +03:00
ddmitry f51048dd23 Merge pull request 'feat(airflow): пульт мира — работники, выключатель, контейнер' (#83) from feat/79-world-control into main
Reviewed-on: #83
2026-08-13 15:09:19 +03:00
ddadminandClaude Opus 5 292302e161 fix(airflow): правки пульта мира по двум линиям ревью
Зачем: линия дефектов нашла опору на дефект провайдера, линия постановки —
незаписанный ответ на вопрос ADR 0009 и переменную образца, чьё значение на
чужой машине неверно, а узнаёт об этом читатель через двести строк.

Что: `auto_remove` у прогона генератора переведён с `success` на `force`.
Значение `success` тоже убирало контейнер в любом исходе, но случайно —
удаление у провайдера 4.5.7 стоит в `finally` вопреки собственной
документации; `force` то же поведение называет прямо и переживёт починку.
У выключателя назван `catchup=False`: умолчание Airflow 3 то же самое, но
у дага с тиком в 25 минут и `start_date` в январе это первый вопрос
читателя. «Быстрый старт» предупреждает про `DOCKER_GID` — единственное
значение образца, неверное вне этой машины. В образе Airflow записано, что
провайдер docker приходит с базой (4.5.7 к 3.3.0), — это ответ на вопрос,
который ADR 0009 оставил тикету. Список томов планировщика получил ту же
пометку «правя одно, правьте второе», что стоит у числа дней. Проход на
вычитание срезал три комментария, пересказывавших ADR.

Проверка: `make lint`, `make config-test`, `make smoke` (20/0),
`make check-clickhouse` (9/9) — зелёные. Живой пульт на чистом стенде:
снятый с паузы `world_live` сыграл два дня подряд без нажатия (24 м 07 с и
24 м 06 с, пауза между ними 55 секунд), дочерний прогон виден ссылкой из
задачи выключателя; пауза остановила мир на границе суток — тик прошёл,
третьего прогона нет. Контейнер после прогона с `force` не остался.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:57:59 +03:00
ddadminandClaude Opus 5 35dc933d29 feat(airflow): пульт мира — работники, выключатель, контейнер
Зачем: модельный день прогонялся только руками, и владельцу нечем было
проверять процессы стенда вживую. Пульт нужен раньше этапа 3 и независимо
от него: он обкатывает то, что приёму заказов понадобится готовым — вызов
генератора из задачи Airflow.

Что: `dags/world_control.py` — три дага по ADR 0009. Работники
`world_next_day` (день пачкой, «сколько дней» параметром) и `world_live_day`
(день в темпе) живут без расписания и без паузы; выключатель `world_live`
создаётся на паузе, тикает раз в 25 минут и дёргает работника живого дня
с ожиданием конца. Генератор зовётся `DockerOperator` в каноническом
контейнере: сокет докера отдан планировщику, потому что при LocalExecutor
задачи исполняет он, а GID группы `docker` уехал в `.env` как локальная
настройка. Позицию на оси ведёт переменная `world_position` — её ставит
сыгравший день работник и только по успеху. Факты стенда — образ, сеть,
брокер, топик, размер стартового мира — даги получают окружением от compose;
внутри compose они названы по разу якорями, иначе разошлись бы с разовой
службой генератора. README получил раздел про пульт с названной вслух платой
за сокет.

Проверка: `make lint`, `make config-test`, `make smoke` (20/0), `make
check-services` (7/0), `make check-clickhouse` (9/9) — зелёные. На чистом
стенде: два прогона `world_next_day` подряд двигают позицию на два дня,
«дней = 3» — на три, все пять дней доехали в ODS; обрыв контейнера позицию
не двигает, повторный запуск играет тот же день с тем же счётом событий.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:09:14 +03:00
ddadminandClaude Opus 5 61bc156d6a docs(adr): решён пульт мира — работники, выключатель, контейнер
Зачем: модельный день прогоняется только руками, и владельцу нечем
проверять процессы стенда вживую. Этап 3 без дага не существует вовсе
(ADR 0008), поэтому форму пульта и способ вызова генератора надо решить
раньше кода.

Что: ADR 0009 — работники без расписания (`world_next_day` пачкой,
`world_live_day` в темпе) и выключатель `world_live` без своей работы,
ждущий дочерний прогон триггером, а не сенсором; генератор зовётся
`DockerOperator` в каноническом контейнере, потому что в образе Airflow ему
не жить (Python 3.14 против 3.13); позиция ставится как «последний сыгранный
+ 1» — правило вместо сторожа, и его мягкий худший исход назван. Плата за
сокет докера снята замером: доступ открывается добавлением GID в контейнер,
правка прав на хосте не нужна, а сам GID — локальная настройка в `.env`.
Пакетная автоматика отложена, не отвергнута. Расхождения внесены в спеку
генератора: позицию ставит сыгравший, у живого дня появился выключатель.
В словарь добавлен «пульт мира».

Проверка: `make config-test` зелёный; API Airflow 3.3 (пауза и ручной
запуск, ожидание триггера по идентификатору прогона, привязка сенсора к
логической дате, `DockerOperator`) сверено через MCP Context7; версия
Python в образе и права сокета — замером.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:35:30 +03:00
ddmitry 9d43ff5489 Merge pull request 'feat(clickhouse): реализована модель доступа' (#82) from feat/78-clickhouse-access into main
Reviewed-on: #82
2026-08-13 11:39:30 +03:00
ddadmin d0f15bea02 fix(clickhouse): устранены замечания ревью модели доступа
- Зачем:
  - документация и конфигурация доступа должны говорить только подтверждённое.
- Что:
  - ADR приведён к результату межшардового замера.
  - объяснены служебный грант и учебный компромисс remote().
  - удалены избыточные профили, README связан с ADR.
- Проверка:
  - make config-test, make lint, make smoke, make check-clickhouse, make check-services.
2026-08-13 11:31:01 +03:00
ddadmin 5627f6c7aa docs(clickhouse): описана учебная модель доступа
- Зачем:
  - читателю нужна честная граница между учебными ролями и боевой защитой.
- Что:
  - README описывает пользователей, роли и место хранения паролей.
  - мастер-спека явно откладывает эксплуатационные меры защиты.
  - устаревшие комментарии Compose и пробника приведены к реализации.
- Проверка:
  - make config-test, make lint.
2026-08-13 10:26:35 +03:00
ddadmin 780b2d0ef9 feat(clickhouse): добавлен пароль служебному default
- Зачем:
  - default должен остаться только учёткой локальных служебных вызовов.
- Что:
  - пароль default подставлен из окружения на обеих нодах.
  - healthcheck и служебные скрипты передают его внутри контейнера.
- Проверка:
  - make config-test, make up, make smoke, make check-clickhouse, make check-services.
2026-08-13 10:23:32 +03:00
ddadmin 62dc618252 feat(clickhouse): приложения переключены на роли
- Зачем:
  - DDL, Airflow и Superset не должны работать с правами default.
- Что:
  - DDL и Airflow переведены на etl, включая явный доступ remote().
  - Superset переведён на bi с паролем из окружения.
  - etl получил право KAFKA, которое ClickHouse 26.3 требует для движка.
- Проверка:
  - make config-test, make lint, make up, make smoke, make check-services.
2026-08-13 10:19:38 +03:00
ddadmin 790f7c1bb7 feat(clickhouse): добавлены роли и межнодовый секрет
- Зачем:
  - менти должен увидеть разделение доступа без состояния в томах.
- Что:
  - пользователи и роли объявлены файлом с паролями из окружения.
  - межшардовые запросы передают пользователя через общий секрет.
  - четыре допущения реализации подтверждены в ADR живыми замерами.
- Проверка:
  - make config-test, make smoke, make check-clickhouse, make check-services.
2026-08-13 10:04:58 +03:00
ddadminandClaude Opus 5 41d057252e docs(agents): у правки комментария свой путь в API
Зачем: резолюции wayfinder-тикетов живут комментариями, и правка после
ревью идёт в них — а дока описывала только правку тела issue. Путь
неочевидный: без номера issue, по идентификатору из ленты.

Что: абзац в разделе про правку через API и строка в «Что проверено и
когда».

Проверка: снято живыми запросами при закрытии #72 — резолюция правилась
дважды этой командой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:04:38 +03:00
ddadminandClaude Opus 5 1fb219f836 docs(adr): решён приём заказов — пакетный забор слепка
- Зачем:
  - развилка этапа 3 стояла нерешённой прямо в разделе 7 мастер-спеки: нужен
    ли слепку слой сырья и как заказы попадают из топика в хранилище (#70).
- Что:
  - заведён ADR 0008 — байтовый чтец без матвью, слой сырья у заказов
    остаётся, в ods.order_snapshot пишет шаг Airflow заменой партиции; топик
    orders в одну партицию, чтец на clickhouse-01 без ON CLUSTER.
  - мастер-спека приведена в соответствие, разделы 6, 7, 9, 11, 12: сравнение
    двух приёмов переписано на «поток против слепка», сенсор дневного батча
    снят, первый даг переехал с этапа 5 на этап 3.
  - в CONTEXT.md заведены «слепок», «окно изменяемости», «пакетный забор».
- Проверка:
  - решение сверено по документации ClickHouse через MCP Context7 12 августа
    2026 года; что осталось замерить на стенде — списком в конце ADR 0008.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:57:37 +03:00
ddadminandClaude Opus 5 a3d852cad9 docs(adr): решена модель доступа к ClickHouse
- Зачем:
  - до появления ETL и витрин надо выбрать границу боевого реализма в
    доступе: один беспарольный default учит нулю, а полноценная защита
    стоит эксплуатации, которой стенду не потянуть (#66).
- Что:
  - принято четыре пользователя и три роли, объявленные файлами настройки,
    без состояния в томах и без SQL-хранилища доступа.
  - межнодовое доверие переведено на общий секрет кластера: учётные данные
    по репликам подменяют права спросившего правами общей учётки.
  - записаны отвергнутые варианты, отложенные меры защиты и четыре
    проверки, которые закрывает тикет реализации.
  - устройство модели отдано разделу README «Состав и доступ»: отдельный
    справочник по доступу своего содержания сверх ADR сегодня не имеет.
  - оговорено, что роль шире прав на слои — образ требует отдельных
    разрешений на ON CLUSTER и на чтение системных таблиц (холодное
    ревью #78).
- Проверка:
  - реализации в этом коммите нет, менять нечему: make config-test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 23:53:18 +03:00
ddmitry d5c5e14cd5 Merge pull request 'refactor(make): корень — про стенд, генератор — за своей дверью' (#77) from refactor/65-make-two-doors into main
Reviewed-on: #77
2026-08-09 22:14:48 +03:00
ddadminandClaude Opus 5 c9b11224d2 refactor(ruff): линтер стенда говорит на языке дагов, а не младшего образа
- Зачем:
  - target-version по младшему образу (Superset, 3.10) занижал проверку для дагов: они бегут на 3.13, а ruff предлагал им идиомы старее их рантайма. На учебном стенде это вывернуто наизнанку — менти читает и правит именно даги.
  - защита от Superset была верна не по устройству, а по сегодняшнему содержимому одного файла настройки.
- Что:
  - target-version в корневом ruff.toml поднят до py313, комментарий переписан.
  - две находки UP017 в дагах починены: datetime.timezone.utc заменён на datetime.UTC.
  - из карты проверок убран пункт про разную строгость дверей — с равными версиями он потерял предмет.
- Проверка:
  - make lint; make config-test — зелёные.
  - make -C generator lint — зелёный; исходники генератора не менялись.
  - находок ruff в дагах теперь ровно четыре, как обещал тикет: два переформата и два UP017.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:58:48 +03:00
ddadminandClaude Opus 5 e4e5688775 refactor(make): правки по холодному ревью реализации
- Зачем:
  - половина критерия приёмки стояла не там, где решено: предупреждение о ручном равенстве версий ruff адресовано тому, кто правит лок в generator/, а лежало в корневом Makefile.
  - довод «генератор — отдельная сущность» был выписан трижды почти дословно.
- Что:
  - равенство версий и охват корневой цели названы в карте проверок; три примечания к таблице собраны списком.
  - названа цена занижения target-version: даги бегут на 3.13 и модернизаций не получают.
  - шапка generator/Makefile вырезана, корневая сжата до строки, объяснение в ruff.toml укорочено.
  - формулировки в AGENTS.md и README поправлены.
- Проверка:
  - make lint; make config-test — зелёные.
  - make -C generator lint; typecheck — зелёные; исходники генератора не менялись.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:49:54 +03:00
ddadminandClaude Opus 5 6a708cd5aa refactor(make): корень — про стенд, генератор — за своей дверью
- Зачем:
  - корневые цели смешивали два уровня: пять из шестнадцати начинались с cd generator.
  - цель, названная общерепозиторной, охватывала 31 файл Python из 34: даги и Superset не видел ни линт, ни типы.
- Что:
  - lint, typecheck, test, docs и inventory переехали в новый generator/Makefile.
  - корневой lint заведён по коду стенда — dags и infra/superset — с явными путями и закреплённой версией ruff.
  - заведён корневой ruff.toml: тот же список правил, target-version по младшему Python в образах стенда.
  - цели корня сгруппированы по использованию, осталось двенадцать.
  - два файла дагов переформатированы под новую проверку.
  - карта проверок, оба README, спека генератора и AGENTS.md приведены к двум дверям.
- Проверка:
  - make lint; make config-test — зелёные.
  - make -C generator lint; typecheck; test — зелёные, 407 тестов.
  - ruff check --show-files: из корня ровно три файла стенда, из generator/ — только его.
  - цена корневого lint замерена (0,4 с) и вписана в карту проверок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:31:07 +03:00
ddmitry 368fb1a66b Merge pull request 'refactor(compose): отделены локальные настройки от фактов стенда' (#76) from feat/60-compose-env-boundary into main
Reviewed-on: #76
2026-08-08 22:01:28 +03:00
ddadmin 704c139d5a refactor(compose): отделены локальные настройки от фактов стенда
- Зачем:
  - устранены дублирование значений и тихая подстановка неполной настройки.
- Что:
  - версии образов и внутренняя топология закреплены рядом с местом использования.
  - имя экземпляра, внешние порты, учётные данные и ключи сделаны обязательными настройками .env.
  - быстрый старт, Dockerfile и статическая проверка приведены к новой границе.
- Проверка:
  - make config-test.
  - docker build для образов Airflow и Superset без аргументов.
  - make up; make smoke; make check-clickhouse; make check-services.
2026-08-08 20:50:35 +03:00
ddmitry 5d012cc222 Merge pull request 'feat(ddl): часовые пояса — линза названа явно' (#75) from docs/63-timezone-convention into main
Reviewed-on: #75
2026-08-08 20:16:55 +03:00
ddadminandClaude Opus 5 76405a06ee docs(storage): у Date пояса нет, и по нему легко промахнуться
- Зачем:
  - фраза «Date не участвует вовсе» выводила тип из-под общего правила:
    про объявление это правда, про употребление — нет. Считая время по
    EventDate без имени пояса, легко получить часы вне диапазона.
- Что:
  - фраза заменена на две: Date хранит только номер дня, пояс при счёте
    времени называют руками, промах виден по часам за границами 0–23.
- Проверка:
  - замерено на стенде: без имени пояса часы от начала суток идут -4…19,
    отрицательных 15 843 события; с названным поясом — 0…23

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:15:56 +03:00
ddadminandClaude Opus 5 a02eba56c2 refactor(generator): имя пояса — комментарием, а не константой
- Зачем:
  - константу COUNTER_TIMEZONE не читал ни один модуль, единственным её
    читателем был тест про неё же; связь имени и смещения держится тем, что
    они стоят в одной строке.
- Что:
  - COUNTER_TIMEZONE снят, имя пояса ушло комментарием к
    COUNTER_TIMEZONE_MINUTES.
  - тест сходимости имени и смещения снят вместе с ним; test_world.py
    вернулся к прежнему виду.
- Проверка:
  - make lint, make typecheck, make test (407 тестов)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 19:56:26 +03:00
ddadminandClaude Opus 5 359570ec65 docs(63): правки по двум холодным ревью реализации
- Зачем:
  - линия дефектов нашла три неверных утверждения и мёртвый замер, линия
    уместности — три пересказа уже сказанного.
- Что:
  - «тип колонки не решает, какое число ляжет» сужено до правды: разбор
    отдаёт готовое число, а пояс приёмника решал бы судьбу строки.
  - замер до правки типов помечен как неповторяемый на нынешнем стенде.
  - правило о поясе сервера привязано к местам, где линза что-то решает:
    матвью приёма пояс не называет, и это не нарушение.
  - убраны: пересказ механики в ADR 0005, четыре строки учебного
    комментария, утверждение о порядке файлов и «секунды от начала эпохи»
    у миллисекундной метки.
- Проверка:
  - make lint, make typecheck, make test (408 тестов)
  - make clean && make up && make check-clickhouse — 9 из 9

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 19:44:53 +03:00
ddadminandClaude Opus 5 d67e697821 feat(ddl): пояс назван явно — в типах колонок и в разборе строки
- Зачем:
  - конвенция #63 записана, а код её не достиг: колонки времени стояли без
    пояса, и сходилось всё лишь потому, что пояс сервера — UTC.
- Что:
  - UTCEventTime объявлен DateTime('UTC'), служебные метки _load_ts и
    kafka_timestamp — DateTime64(3, 'UTC') в STG и ODS.
  - parseDateTimeOrNull получил третьим аргументом 'UTC': маска сверяет
    суффикс Z как букву, зоны из строки не берёт вовсе.
  - контракт схемы и описание выгрузки несут тип с поясом; имя пояса
    Europe/Samara встало рядом со смещением в world.py, сходимость сверяет
    тест.
  - учебный комментарий о линзе — у первой колонки с явным поясом.
- Проверка:
  - make lint, make typecheck, make test (408 тестов)
  - make clean && make up && make check-clickhouse — 9 из 9
  - замер тикета повторён: под session_timezone='Europe/Samara' колонка
    показана 2026-05-31 23:37:00, как и без настроек

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 19:30:50 +03:00
ddadmin e6f300a66e docs(storage): правки конвенции по двум холодным ревью
- Зачем:
  - раздел «Часовые пояса» прошёл два холодных ревью — по дефектам и по
    уместности. Первое поймало ложный замер и три расхождения с живым
    стендом, второе — материал не своей зоны и дубли (#63).
- Что:
  - замер «расхождение живёт по HTTP» отозван: мерил toString(UTCEventTime)
    в родном клиенте против голой колонки по HTTP, а это разные вещи.
    Перемерено — клиенты ведут себя одинаково; записан верный факт: вывод
    колонки идёт по поясу сессии, функция — по поясу типа.
  - «по поясу сервера» заменено на «по поясу сессии, а тот по умолчанию
    серверный» — в разделе и в ADR 0005; утверждение в ледгере переписано
    под измеренный раскол вывода и типа.
  - PARTITION BY toDate(_load_ts) больше не выдаётся за уже соблюдённое
    правило: _load_ts сегодня DateTime64(3) без пояса.
  - абзац ADR 0005 больше не спорит с цитатой вызова строкой выше.
  - вырезано: веер отклонённых вариантов под заголовком (живые отказы
    разложены прозой по своим абзацам, как принято в этом документе),
    ссылка на несуществующую связку в world.py, осиротевшая строка про
    Grafana, абзац про пояс показа — он уехал комментарием в #63.
- Проверка:
  - make lint
  - замеры повторены на живом стенде 8 августа 2026 года
  - DDL к конвенции по-прежнему не приведён: документы описывают цель
2026-08-08 19:05:39 +03:00
ddadmin a93a3bf3a3 docs(storage): конвенция часовых поясов принята и записана
- Зачем:
  - пояс в стенде нигде не назван: числа верны только потому, что сервер
    ClickHouse стоит в UTC, а правило понадобится в dds и витринах —
    воронки, удержание, «покупки по дням» (#63).
- Что:
  - раздел «Часовые пояса»: пояс — линза, называется в типе колонки либо в
    вызове; какая именно — решает слой (ODS на языке выгрузки, DDS и витрины
    на языке бизнеса); день берётся из EventDate.
  - названы оба перехода, где пояс выбирается, включая разбор строки в
    матвью — он берёт пояс у сервера и тип колонки этого не чинит.
  - отвергнутые варианты прозой: умолчание сервера, TZ серверу, ODS в поясе
    счётчика, хранение местного времени.
  - три замера ушли в «Что проверено», сверка с документацией — от 8 августа.
- Проверка:
  - make lint
  - DDL к конвенции ещё не приведён: документ описывает цель, код идёт
    следом тем же тикетом.
2026-08-08 18:36:29 +03:00
ddmitry c382b0390f Merge pull request 'docs: приёмка этапа 2 и порог, который оказался гигиеной' (#67) from docs/priyomka-etapa-2 into main
Reviewed-on: #67
2026-08-07 20:56:34 +03:00
ddadmin 2951359e6c docs(spec): порог приёмки — гигиена, и живёт он в карте проверок
Зачем: формулировка в спеке дублировала строку карты проверок и делала это
хуже оригинала. «make up работает» следует из зелёных проверок и потому
пусто; настоящее условие — «с нуля» — в спеке не проговаривалось. Заодно
гигиеническая планка носила имя приёмки этапа, хотя про предмет этапа она
молчит: дашборд может быть не нарисован, а все три цели зелены. Тот же промах
разобран в ADR 0004 — там он случился с числовым порогом.

Что: раздел 9 спеки отсылает к карте проверок вместо своей формулировки и
называет вещь своим именем — гигиена, а не приёмка. Копия строки убрана из
семи карт этапов и карты #1 в трекере: одна вещь — одно место.

Проверка: правка документная. grep по docs, README и AGENTS — других копий
формулировки нет.
2026-08-07 20:26:29 +03:00
ddadmin 6f5f76fafb docs(stage-2): приёмка этапа 2 — снятые замеры и решение по дублям Kafka
Зачем: этап 2 принят прогоном с нуля, и два его следа должны остаться в
доках — иначе решение «не проверяем» станет забытым долгом, а цена цели
разойдётся с замером.

Что:
- в спеке v2 раздел 11 больше не держит Kafka Engine на двух нодах: дубли и
  раскладку партиций между прогонами решено не проверять — дубль возможен по
  устройству движка, окно разобрано в доке хранилища, в ODS его схлопывает
  ReplacingMergeTree. Половина, которую показал #37, названа;
- в карте проверок цена check-services 44 с -> 59 с и абзац с замерами
  приёмки: clean+up 2 м 58 с, смоук 9 с, check-clickhouse 8 с, test 71 с.
  Причина подорожания не выдумывается — названа неизвестной.

Проверка: make clean && make up, затем make smoke, make check-clickhouse,
make check-services, make lint, make typecheck, make test — всё зелёное
7 августа 2026 года.
2026-08-07 20:14:35 +03:00
ddmitry 600bd74b04 Merge pull request 'feat(stand): стартовый мир при make up и опись, которая его сторожит' (#64) from feat/42-world-manifest into main
Reviewed-on: #64
2026-08-07 19:34:14 +03:00
ddadmin c696fce40b fix(stand): находки ревью — диагноз не утверждает причину, комментарии не врут
Зачем
Холодное ревью нашло три места, где написанное сильнее сделанного.

Что
- Непустая таблица брака больше не выдаётся за доказательство сломанного
  разбора. Модельного дня у брака нет, обрамить его нечем, и строки прежних
  уроков лежат в нём месяц: после первого же урока с мусором проверка
  давала бы неверный диагноз навсегда. Теперь она даёт признак, по которому
  причину отличают, — сошлась недостача с числом брака или нет.
- Комментарий у world-init обещал, что расхождение числа дней с
  STARTING_DAYS поймают счётчики. Это неправда в одну сторону: лишний день
  ложится за рамкой дат описи. Обещание убрано, дыра названа.
- Довод «даг next_day этапа 5 продолжит ось» опирался на несуществующий
  этап; заменён настоящей причиной — заливка замыкает цепь разовых служб.
- Потолок ожидания 300 с получил обоснование замером с кратностью, а сам
  скрипт — честную оговорку: его обещание работает на пустом стенде, на
  живом ждать нечего.
- Третья, пропущенная ссылка на снятый порог скорости дня убрана из спеки.
- Даты замеров в карте целей разведены: #42 менял три цели, а не шесть.

Проверка
Обе ветви диагноза сняты заново на живом стенде: без брака — «не доехали»,
с браком — признак различения. Стенд восстановлен, все 9 проверок зелёные,
брака 0. make lint, typecheck, config-test, test (407 тестов) зелёные.

Ссылка: #42
2026-08-07 18:47:13 +03:00
ddadmin 7c9eeedc40 feat(stand): make up наполняет стенд стартовым миром, опись сторожит его
Зачем
Стенд поднимался пустым, и всякая приёмка следующих этапов начиналась с
ручной заливки данных. Теперь `make up` сам приводит стенд к одному и тому
же состоянию, а в git лежит то, чем это состояние проверяется.

Что
- Опись мира `data/world-inventory.json`: паспорт (зерно, версия
  генератора, хеш каталога) и по строке на каждый из восьми дней — дата,
  число событий, хеш байтов. Собирается `make inventory`, свежесть сторожит
  `test_inventory.py` — тем же способом, что свежесть описания выгрузки.
- Разовая служба `world-init` вышла из-под профиля и играет в топик восемь
  дней при каждом подъёме; зависимый у неё — `airflow-init`, иначе `--wait`
  считает успешно отработавшую службу упавшей.
- `scripts/wait-for-world.sh` — вторая половина `make up`: приём
  асинхронный, поэтому ждать надо доезда до `ods.event`, а не завершения
  заливки. Ограниченный цикл опроса, не пауза наугад.
- Девятая проверка `make check-clickhouse`: подневный счёт событий против
  описи, рамка по датам стартового мира, счёт через `FINAL`. При
  расхождении называет, где искать, — в событиях или в браке.
- Порог «день ≤ 30 с» снят из спеки генератора в обоих местах: замер дал
  1,7 с, порог был выше факта в восемнадцать раз. На его месте — замеры с
  датой. Раздел 9 спеки закрыт: открытых вопросов не осталось.
- Слова: «манифест» стал описью мира, «зерновой мир» — стартовым миром
  (решение владельца). Оба заведены в словарь CONTEXT.md.

Проверка
`make clean && make up` с нуля — 2 м 50 с, доехало ровно 401 185 событий.
`make check-clickhouse` зелёный (8 с), `make smoke` зелёный (9 с),
`make test` — 407 тестов за 71 с, `make lint`, `make typecheck`,
`make config-test` зелёные.

Что проверка умеет краснеть, снято двумя поломками: снос партиции
2026-06-03 дал диагноз «не доехали до ODS», негодная строка в сырье —
«сломан разбор». Строки опыта убраны, день переигран, счёт вернулся.
Тест свежести проверен молчаливой правкой цены в каталоге: покраснел.

Ссылка: #42
2026-08-07 18:37:01 +03:00
ddadminandClaude Opus 5 30a1e9e567 docs(testing): корректность процессов — в даги DQ, не в цели make
- Зачем:
  - находка «это стоило бы проверять регулярно» решалась заново в каждом
    тикете и каждый раз тянулась в make check-clickhouse. У неё есть
    назначенный дом: даги качества данных, которые придут со следующими
    уровнями хранилища.
- Что:
  - добавлен раздел «Корректность процессов живёт в дагах DQ, а не в целях
    make»: цели make отвечают «стенд собран», свойства данных — работа дага.
  - назван фильтр: про полноту дня, свежесть слоя, сходимость витрины с
    источником — это даг, а не цель.
  - названа учебная сторона: даг идёт по расписанию, пишет историю проверок
    и разбирается как обычная задача Airflow — так качество данных устроено
    в бою.
- Проверка:
  - make config-test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:39:49 +03:00
ddmitry f3ebc115ba Merge pull request 'feat(ods): типизированное событие, строгий приём и таблица ошибок' (#62) from feat/43-ods-event into main
Reviewed-on: #62
2026-08-07 16:50:44 +03:00
ddadminandClaude Opus 5 a534f3cc94 fix(ods): метка времени разбирается по названному формату, а не best-effort
Зачем: parseDateTimeBestEffort на непонятной строке не краснеет, а достраивает
недостающее — обрезанное «20:00:21» становится первым января текущего года.
Такое сообщение проходило строгий приём с тихо неверным временем, то есть с
той самой порчей, ради которой класс key_field_unparsed и заведён.

Что:
- В обеих матвью разбор метки идёт parseDateTimeOrNull по формату
  '%Y-%m-%dT%H:%i:%SZ'. Форма на проводе одна и каноническая, поэтому широта
  best-effort не нужна вовсе, а платится за неё отключённой проверкой.
- Замеры в ADR 0005: три записи, которые best-effort достраивает; проверка,
  что настройка cast_string_to_date_time_mode не спасает JSONExtract; сверка
  на настоящих данных — по всем 101 252 строкам сырья модельного дня точный
  формат разобрал метку у каждой и ни на одной не разошёлся с best-effort.
- Записано наблюдение стенда: пересозданная на живом чтеце матвью пропускает
  ближайшее сообщение мимо ODS, через минуту то же сообщение разбирается.
  Воспроизведено дважды; на нём я сам споткнулся при проверке этой правки.

Проверка: опыт строгого приёма прогнан заново — три сообщения дали событие и
два key_field_unparsed, включая обрезанную метку, которая раньше проходила
годной. DDL применяется на живом кластере.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:48:37 +03:00
ddadminandClaude Opus 5 68f789ba91 docs(ods): находки ревью — опыт с _load_ts, точность формулировок, рез повторов
Зачем: холодное ревью по двум линиям нашло дыру в следе опытов и три места,
где текст утверждает не то, что построено.

Что:
- Опыт «_load_ts переносится из сырья» прогнан и записан: у двух тысяч
  событий метка совпала с меткой одной из доставок, случаев «метки нет среди
  доставок» ноль. Туда же — ответ про форму ключа ODS: вопрос раздела 11
  спеки закрывался молча.
- Дока хранилища говорила, что предикат собран из функций, не возвращающих
  NULL; построено иначе — обнуляемый разбор есть, но кончается IS NOT NULL.
- Записана гарантия на JSONType: на не-JSON и пустой строке она отдаёт Null и
  не бросает, то есть годится в предикат. Раньше первый класс брака стоял на
  замере соседней функции.
- ttl_only_drop_parts у таблицы ошибок назван в доке хранилища.
- Комментарий матвью ужат: три вопроса строгого приёма пересказывали ADR 0005
  целиком. Осталось то, чего по коду не видно, — запрет трогать arraySort и
  замер про ISO-8601. Убрано неверное «в полусотне строк» и упоминание имени
  таблицы хранилища в докстринге контракта генератора.

Проверка: DDL применяется на живом кластере; make lint, typecheck, docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:16:09 +03:00
ddadminandClaude Opus 5 6910440400 feat(ods): типизированное событие, строгий приём и таблица ошибок
Зачем: цепочка Kafka → STG → ODS достраивается последним этажом. Сырьё уже
доезжает (#37), настоящие события в топике есть (#41), а типизированного слоя
не было — событие негде было прочитать колонками, а брак негде увидеть.

Что:
- sql/ddl/20-ods-tables.sql — ods.event_rep/_dist на ReplacingMergeTree с
  версией _load_ts, партиция по EventDate, ключ по разделу 1.3 спеки,
  шардирование cityHash64(ClientID); ods.event_errors_rep/_dist с классом
  брака, своими ключами и сроком жизни в месяц.
- sql/ddl/30-ods-views.sql — две матвью над stg.hits_raw_dist. Годность
  считает предикат из трёх частей, вторая матвью берёт его дословное
  отрицание, класс брака пишется первым совпавшим из трёх.
- Метку времени разбирает parseDateTimeBestEffortOrNull, а не JSONExtract:
  ISO-8601 с суффиксом Z JSONExtract не берёт вовсе. Спека генератора
  обещала обратное — обещание поправлено, форма на проводе не менялась.
- Сверка объявлений (contract-тест) снята из документов и из докстрингов
  schema.py: сверх строгого приёма она ловила только смену типа.
- Документация приведена в соответствие: ADR 0005, дока хранилища и обе
  спеки; группа «сказано по памяти» в доке хранилища опустела.

Проверка: make up && make check-clickhouse (8 проверок, 7,5 с); make lint,
make typecheck, make test (406), make docs без диффа. Разовые опыты при
исполнении — в теле PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:06:46 +03:00
ddadminandClaude Opus 5 d7485217d8 refactor(scripts): clickhouse-smoke.sh переименован в check-clickhouse.sh
Зачем: имя файла осталось от прежней цели smoke-cluster, которой больше нет,
и читатель ищет проверку ClickHouse не там, где она лежит.

Что: git mv scripts/clickhouse-smoke.sh scripts/check-clickhouse.sh, тем же
коммитом — вызов в Makefile и строка в карте целей. Абзац-объяснение в
docs/architecture/testing.md снят: он обещал переименование, которое здесь и
случилось.

Проверка: make check-clickhouse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:33:44 +03:00
ddmitry 77fa2905a2 Merge pull request 'feat(generator): сериализатор, приёмники, проигрыватель и запуск контейнером' (#61) from feat/41-serializer-sinks-cli into main
Reviewed-on: #61
2026-08-07 14:37:22 +03:00
ddadminandClaude Opus 5 a61f7934ec feat(generator): сериализатор, приёмники, проигрыватель и запуск контейнером
- Зачем:
  - до сих пор генератор умел собирать день, но не умел его отдать: топик
    hits наполнялся пробником, а не настоящими данными. Тикет #41 доводит
    события до стенда и закрывает форму на проводе, на которую обопрётся
    типизированный ODS (#43).
  - сериализатор один по решению спеки: второе место, печатающее событие в
    JSON, разошлось бы с первым молча.
- Что:
  - serialize.py — канонический сериализатор на orjson: единственное место,
    где событие целиком становится JSON; 47 ключей всегда, «пусто» это
    пустое значение, даты ISO-8601, ecommerce строкой. Вложенный блок
    ecommerce в commerce.py вторым сериализатором не считается — правило
    про событие, а не про блок внутри него.
  - sinks.py — приёмники: файл (одно событие — одна строка) и Kafka (одно
    событие — одно сообщение). Ключа у сообщения нет: WatchID уникален,
    ключом он был бы ключом лишь на вид.
  - player.py, cli.py — проигрыватель и интерфейс запуска: режимы batch и
    live (темп ×60), несколько дней одним запуском, ограниченная пачка,
    раздельные тайминги генерации и доставки, лаг в логе.
  - день на оси и имя топика умолчаний не имеют: параметр, описывающий
    среду или позицию, приходит от зовущего, иначе отказ до генерации.
    Умолчания зерна, числа дней и темпа остаются — они описывают мир.
  - generator/Dockerfile — свой образ: зависимости из uv.lock, база
    закреплена до патча, раскладка репозитория сохранена ради каталога
    товаров. Образ Airflow не тронут.
  - разовая служба compose под профилем, цели generate-batch и
    generate-live, .dockerignore, tmp/ в .gitignore.
  - решения внесены в спеку (разделы 4, 8, 9), быстрый старт — в README.
- Проверка:
  - make test 406 passed, make lint, make typecheck, make config-test.
  - побайтовый детерминизм: два прогона дня в независимых процессах дают
    один sha256; день в контейнере совпадает с днём на машине.
  - на стенде: пакетный день доехал до stg.hits_raw_dist, счёт по
    Distributed сошёлся — отправлено 50626, в таблице 50626.
  - топик прочитан обеими нодами: clickhouse-01 раздел 0 (26368),
    clickhouse-02 раздел 1 (24258).
  - живой день: модельное время 01:00 на 60-й секунде, 02:00 на 120-й —
    темп ×60, лаг печатается.
  - форма на проводе в колонке raw: даты читаются глазами, ecommerce лежит
    строкой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:05:11 +03:00
ddadminandClaude Opus 5 8903b6a054 docs(agents): усложнение не бесплатно — платит учебная ценность
- Зачем:
  - у вопроса «чему на этом научится менти?» не хватало второй половины:
    он отсеивал бесполезное, но не взвешивал цену полезного. Стенд
    учебный, и чем он сложнее, тем хуже как учебный материал.
  - прогон #41 показал механизм: две слепые линии ревью дали 15 находок,
    саморевью ещё 7, и каждая по устройству триажа превратилась в правку.
    Шага, на котором кто-нибудь вычитает, в конвейере не было ни разу —
    только воронка. Проход на вычитание потом срезал 149 строк и 6 тестов,
    и всё срезанное появилось после ревью, а не при замысле.
- Что:
  - в раздел «Цель репозитория» добавлены три абзаца: внимание менти как
    конечная валюта и вопрос «что он платит и что получает»; почему
    оборонительный код дороже прочего и врёт про опасность; проход на
    вычитание как обязательный шаг перед приёмкой заметной работы.
  - записано частное правило, выведенное владельцем на двух резах подряд:
    не сторожить ошибку, которую человек делает сам себе и тут же видит —
    разбирательство с ней и есть урок.
- Проверка:
  - правка текстовая, целей make не задевает; make config-test зелёный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:04:43 +03:00
ddadminandClaude Opus 5 b8816466d9 docs(testing): интеграционная проверка не живёт постоянной целью
- Зачем:
  - проверка, которая кормит стенд данными, оставляет их в мире менти
    навсегда: у ODS срока хранения нет, и события, которых мир не рождал,
    неотличимы в лабах от настоящих. Решение принято при разборе постановок
    #41 и #43, чтобы оно решалось по карте, а не заново в каждом тикете.
- Что:
  - добавлен раздел «Интеграционная проверка постоянной целью не становится»:
    такой прогон делается один раз при исполнении, след — запись в теле PR.
  - названо требование прибираться за собой и дешёвый способ это сделать:
    модельный день за границей оси мира и снос его партиции.
  - названо, что стережёт цепочку постоянно вместо неё — проверки на
    настоящих данных, которые стенд произвёл сам.
- Проверка:
  - make config-test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:22:56 +03:00
ddmitry 896c846cbc Merge pull request 'refactor(config-test): срезан разбор Bash — он не ловил ничего' (#59) from refactor/config-test-bez-razbora-bash into main
Reviewed-on: #59
2026-08-06 17:23:58 +03:00
ddadminandClaude Opus 5 5492e6ff3d refactor(config-test): срезан разбор Bash — он не ловил ничего
- Зачем:
  - проверка была сломана с рождения цели: bash -n со списком файлов
    разбирает только первый, остальные уходят ему в аргументы. Из пяти
    скриптов проверялся один, и за всё время этого никто не заметил.
  - чинить незачем: скрипты стенда запускают с той же машины, и
    синтаксическая ошибка вылезает при первом же запуске с номером строки.
    Учебной ценности в проверке нет — из неё не узнаёшь ничего, кроме того,
    что у bash есть ключ -n.
  - держалась она не строчкой, а двенадцатью: обход репозитория, временный
    файл со списком, mapfile и две ветки на пустой список.
- Что:
  - из scripts/config-test.sh убраны разбор Bash и весь аппарат сбора
    списка файлов; 53 строки стали 40.
  - разбор файлов DAG остался и получил комментарий с основанием: их на
    машине не запускает никто, обработчик разбирает их внутри контейнера, и
    ошибка всплывает не сообщением, а молча пропавшим DAG.
  - README и карта проверок больше не обещают проверку синтаксиса Bash.
  - в карте записано, почему проверку не стоит заводить заново.
- Проверка:
  - make config-test зелёный.
  - оставшийся разбор DAG краснеет: незакрытая скобка в dags/test_kafka.py
    роняет цель с SyntaxError и ненулевым кодом; файл восстановлен.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:59:44 +03:00
ddmitry 4245daf3f1 Merge pull request 'refactor(smoke): цели проверки по назначению — смоук, ClickHouse, службы' (#58) from refactor/54-proverki-po-naznacheniyu into main
Reviewed-on: #58
2026-08-06 16:04:38 +03:00
ddadminandClaude Opus 5 7e4d5815a3 feat(smoke): смоук спрашивает Kafka снаружи — по объявленному адресу
- Зачем:
  - деление целей оставило дыру: про Airflow, Superset, Prometheus и Grafana
    смоук стучится с машины в отображённый порт, а про Kafka после переезда
    check_kafka_from_host знал только «контейнер здоров».
  - вердикт этот приходит из healthcheck в compose.yaml, а тот спрашивает
    брокер изнутри и по внутреннему слушателю: объявленный наружу адрес может
    вести не туда, и Kafka всё равно останется здоровой.
  - поломка популярная и показательная: клиент подключается, получает
    метаданные и молча виснет на адресе, которого с его стороны нет. Менти
    узнаёт, что у брокера два слушателя и зачем нужен advertised.listeners.
    Генератор будет писать в Kafka именно с машины.
- Что:
  - check_kafka_external_listener в make smoke: запрос списка топиков с машины
    через отображённый порт, ответ приходит только если объявленный адрес ведёт
    туда же. Комментарий у проверки объясняет, от чего она заведена.
  - ожидание ответа ограничено 15 секундами при замеренных 2,6 — впятеро
    больше, чем стоит зелёный прогон.
  - README и карта проверок: новая проверка названа, доводы записаны, цена
    смоука обновлена с 6 до 8 секунд.
- Проверка:
  - make config-test, make smoke (20 проверок, 8 с), make check-clickhouse,
    make check-services — зелёные.
  - краснеет на своей поломке: брокеру объявлен адрес kafka-nowhere:29092 при
    целом внутреннем слушателе — проверка состояния контейнера осталась
    зелёной, смоук покраснел именно на этой строке. После проверки Kafka
    возвращена в исходное состояние, посторонних контейнеров не осталось.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:20:27 +03:00
ddadminandClaude Opus 5 bbbe17cfb0 refactor(smoke): цели проверки по назначению — смоук, ClickHouse, службы
- Зачем:
  - смоук перестал быть быстрым: 42 секунды из 48 съедали шесть проверок,
    которые ждут службу — запуск DAG, вход в Superset, Kafka с машины.
  - имена целей врали: смоуком звались и глубокая проверка кластера, и
    интеграционные проверки; префикс достался им от общего происхождения.
  - нигде не было записано, зачем в репозитории каждая цель и куда класть
    новую проверку, — без записи скрипт дорастёт снова.
- Что:
  - ось деления — кого спрашивают, а не сколько стоит: make smoke (стенд
    собран), make check-clickhouse (спрашивают у ClickHouse), новая
    make check-services (службы работают).
  - шесть тяжёлых проверок переехали в scripts/stand-services.sh; общее —
    счёт, обращение к Compose, зависимости машины и check_containers_survived
    — вынесено в scripts/stand-common.sh, копипасты нет.
  - smoke-cluster переименована в check-clickhouse; имя файла скрипта не
    тронуто (в него встраивается проверка договора со схемой), расхождение
    названо в карте.
  - смоук и check-services печатают своё время в строке ИТОГ; порога по
    времени нет — по доводу ADR 0004.
  - docs/architecture/testing.md: карта всех семи целей, правило быстрого
    смоука словами, лесенка по частоте и правило про краснеющую проверку,
    переехавшее из README; указатель из AGENTS.md.
  - README: описания целей сокращены, карта не дублируется; быстрый старт
    показывает работающий стенд, а не только собранный.
  - планка приёмки этапа в спеке названа поимённо: три цели вместо
    «smoke-проверки».
- Проверка:
  - make config-test, make smoke (19 проверок, 6 с), make check-clickhouse
    (8 проверок, 7 с), make check-services (7 проверок, 44 с) — зелёные.
  - 19 + 7 = 25 разных проверок, как и до деления: check_containers_survived
    считается дважды намеренно.
  - краснеют обе разделённые цели: со снятым prometheus смоук дал три ошибки,
    с подменённым UUID подключения Superset покраснел check-services.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:34:02 +03:00
ddmitry f84f8c416f Merge pull request 'refactor(smoke): срезаны проверки стенда, не служащие менти' (#57) from refactor/56-srezat-proverki-stenda into main
Reviewed-on: #57
2026-08-06 14:10:44 +03:00
ddadmin a2f5b27d89 refactor(smoke): ограничен по времени опрос keeper, уточнены формулировки
- Зачем:
  - горячее ревью: перевезённая проверка keeper потеряла привычку файла
    ограничивать обращения к контейнерам по времени и глушить их ошибки.
- Что:
  - опрос keeper идёт через keeper_exec с timeout 20s и тихим stderr,
    в отчёте об отказе пустой ответ назван словами.
  - комментарий к проверке и абзац README переписаны на проверяемое
    утверждение: настройки объявлены в compose.yaml, смоук спрашивает,
    дошли ли они до процесса.
  - в пробнике ClickHouse ожидаемой строке возвращено имя expected_rows.
- Проверка:
  - make config-test, make smoke, make smoke-cluster; отдельно проверено,
    что на паузе keeper проверка краснеет, а не виснет.
2026-08-06 14:01:45 +03:00
ddadmin 66490d124e refactor(smoke): срезаны проверки стенда, не служащие менти
- Зачем:
  - проверок стало больше, чем продукта, и росли они из критериев приёмки,
    а не из учебной ценности (#56).
- Что:
  - удалены tests/dag-probes-unit.py, tests/stand-smoke-guards.sh,
    tests/stand-smoke-static.sh и tests/smoke-guards.sh вместе с целью
    make smoke-guards и запуском юнит-тестов в scripts/config-test.sh.
  - из scripts/stand-smoke.sh убран check_env_consistency, туда же переехал
    check_keeper_runtime; счёт проверок остался 25.
  - в пробниках свёрнуты функции _assert_*, комментарий про отложенный импорт
    переписан на причину из документации Airflow и продублирован в test_kafka.
- Проверка:
  - make config-test, make up, make smoke, make smoke-cluster.
2026-08-06 13:06:44 +03:00
ddadminandClaude Opus 5 6afa26b7be docs(agents): вопрос об учебной ценности как шлагбаум
Зачем: учебная ценность в AGENTS.md заявлена дважды, но обе строки —
утверждения о ценности, а не действие в момент письма. При них проверок
в репозитории выросло больше, чем продукта.

Что: в раздел «Цель репозитория» добавлен проверяемый вопрос к любой
доработке — чему на ней научится менти — с ветвью: не складывается ответ,
значит это вопрос владельцу, а не строчка кода.

Проверка: не требуется, правка документа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:37:06 +03:00
ddmitry 18200fb97f Merge pull request 'docs(agents): правка тела issue идёт через API, а не через tea' (#55) from docs/issue-tracker-api-recipes into main
Reviewed-on: #55
2026-08-06 09:28:04 +03:00
ddadminandClaude Opus 5 dd6d117d49 docs(agents): правка тела issue идёт через API, а не через tea
Зачем: тикнуть чекбокс в критериях приёмки нужно при закрытии каждой задачи,
а рецепта в доке не было. Наступили на это при закрытии #37: `tea issues edit
--description` требует тело целиком строкой, но взять её неоткуда — вывод
`tea issues <номер>` обёрнут и разрисован для терминала.

Что: раздел «Правка тела issue — только через API» с рабочим рецептом
(забрать сырой JSON, поправить body, вернуть через `-X PATCH -d @файл`) и
разбором флагов `tea api`. Оттуда же общее правило: CLI удобен, пока команда
создаёт объект или меняет его свойство, и мешает, как только надо изменить
уже написанный текст. Ссылка из раздела про автозакрытие и запись в «Что
проверено и когда».

Проверка: рецепт снят живыми запросами при закрытии #37 — так проставлены
чекбоксы тикета и пункт в чек-листе карты #4. `make config-test` зелёный.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:24:53 +03:00
ddmitry c99e853fd3 Merge pull request 'feat(stg): DDL-бутстрап, топик hits и приём сырья обеими нодами' (#53) from feat/37-ddl-bootstrap-stg-ingest into main
Reviewed-on: #53
2026-08-06 09:21:18 +03:00
ddadminandClaude Opus 5 daf13384a8 feat(stg): DDL-бутстрап, топик hits и приём сырья обеими нодами
Зачем: стенду нужен воспроизводимый холодный старт, при котором схема
хранилища и топик появляются сами, а сырьё из Kafka доезжает в STG обеими
нодами кластера — без ручных шагов между `make clean` и рабочим приёмом.

Что:
- `sql/ddl/` — три файла, применяются по порядку имён: базы `stg` и `ods`,
  Kafka-чтец `hits_raw_kafka` формата RawBLOB, реплицируемая `hits_raw_rep`
  с окном TTL в трое суток, распределённая `hits_raw_dist` и матвью
  `hits_raw_mv`, переносящая сырьё вместе с метаданными доставки.
- `compose.yaml` — службы `kafka-init` (топик `hits` на две партиции, с
  ремонтом уже созданного однопартиционного) и `clickhouse-init` (применяет
  `/ddl/*.sql`); `hostname:` у обеих нод, чтобы `hostName()` отдавал имя узла,
  а не идентификатор контейнера; `airflow-init` зависит от `clickhouse-init` —
  без зависимого успешный одноразовый сервис считается упавшим для `--wait`.
- Доки: конвенции и раздел «Что проверено» в справочнике хранилища, указатели
  и границы обещаний в ADR 0005, снятые пункты в разделе 11 спеки.

Проверка: `make lint`, `make typecheck`, `make config-test`, `make smoke`
(25 проверок), `make smoke-guards` — зелёные. Приёмочный прогон с чистого
тома подтвердил все пять критериев #37: холодный старт и идемпотентный
повтор, две партиции у `hits`, метаданные доставки у доехавшего сообщения,
обе партиции на обеих потребляющих нодах в одном прогоне, некорректный JSON
лежит сырым и приём не встаёт.

Известная граница: RawBLOB молча теряет запись с пустым значением и
запись-надгробие; принято как свойство, замер и довод — в справочнике
хранилища.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 08:04:25 +03:00
ddmitry a0144e0fff Merge pull request 'docs(storage): конвенции хранилища и приём событий перед этапом 2' (#52) from docs/37-storage-conventions into main
Reviewed-on: #52
2026-08-05 22:09:39 +03:00
ddadminandClaude Opus 5 923ebad80e docs(storage): связность восстановлена, форма дат на проводе задана
- Зачем:
  - холодное ревью связности нашло девять мест, где вставленный текст спорит с
    соседним; отдельно вскрылось, что представление дат в JSON не зафиксировано
    нигде, а #43 обязан его знать раньше, чем #41 напишет сериализатор.
- Что:
  - гарантия приёма переписана: после снятия синхронной вставки «хотя бы один
    раз» стало неправдой — есть и окно потери, и окно дубля.
  - критерий выбора пяти опорных колонок приведён к списку, который он
    порождает; `CounterID` оговорён отдельно.
  - «переобработки у ODS нет вовсе» смягчено до пакетной: ручная вставка из
    сырья в пределах окна возможна.
  - в спеку генератора добавлена форма дат на проводе — ISO-8601, с доводом от
    читаемости слоя сырья.
  - убраны осиротевшая фраза про порядок сервисов, дубль порядка классов брака,
    устаревшая датировка сверки и ещё три следа вставок.
- Проверка:
  - make config-test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:53:03 +03:00
ddadminandClaude Opus 5 f1254ce79d docs(handoff): передача контекста удалена как отработавшая
- Зачем:
  - список «чего не переоткрывать» устарел за день: снятая настройка
    синхронной вставки и прежняя раскладка DDL остались в нём как принятые
    решения, а документ, утверждающий решённым переехавшее, — ловушка.
- Что:
  - удалён .scratch/handoffs/2026-08-04-storage-conventions.md; содержание
    живёт в ADR 0005 и 0006, доке хранилища и тикетах #37 и #43.
- Проверка:
  - make config-test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:31:32 +03:00
ddadminandClaude Opus 5 31b274175a docs(storage): конвенции и приём событий выправлены после ревью
- Зачем:
  - три холодных ревью и сверка с документацией ClickHouse нашли противоречия
    между докой, ADR и спекой: исполнитель #37 получал два разных ответа на
    один вопрос, а два утверждения о движке оказались неверными.
- Что:
  - раскладка файлов DDL перестроена — сначала таблицы, матвью приёма
    последней: иначе часть событий тихо минует ODS.
  - синхронная вставка снята с пути приёма: настройка недостижима для потока
    Kafka-движка и связывает шарды; на ETL-вставках осталась.
  - у таблицы ошибок появился класс брака с порядком проверки, у сырья и
    ошибок названы движки и ключи сортировки.
  - в доку добавлен раздел «Что проверено»: сверенное с документацией,
    проверяемое на стенде и сказанное по памяти разведены.
  - в спеке выправлены источник матвью разбора, пять опорных колонок, имена
    четырёх витрин и ссылка на несуществующую цель make.
- Проверка:
  - make config-test
  - grep по устаревшим именам файлов DDL и витрин — пусто

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:25:35 +03:00
ddadmin dedb6c6739 docs(handoff): передача контекста по конвенциям хранилища
- Зачем:
  - работа над #37 продолжится в новой сессии, а решения сессии разбросаны по
    двум ADR, доке хранилища, двум спекам и двум тикетам; свежему агенту нужен
    один вход с указателями и списком того, что переоткрывать не надо.
- Что:
  - добавлен .scratch/handoffs/2026-08-04-storage-conventions.md: указатели на
    артефакты, перечень принятых развилок, открытые хвосты и особенности
    работы с трекером.
- Проверка:
  - make config-test
2026-08-04 00:21:43 +03:00
ddadmin 319db308bf docs(storage): приняты решения по приёму событий и именам
- Зачем:
  - тикет #37 молча опирался на конвенции хранилища, которых в проекте не
    было; без них #43 и следующие этапы разъехались бы в именах, служебных
    колонках и механике приёма.
- Что:
  - ADR 0005: топик читается байтами в STG, разбор идёт функциями в матвью
    ODS; строгий приём — сверка набора ключей плюс Nullable на пяти опорных
    колонках.
  - ADR 0006: суффикс вида в именах объектов (_rep, _dist, _kafka, _mv, _v).
  - docs/architecture/storage.md: конвенции имён и служебных колонок, путь в
    keeper, раскладка по шардам, срок жизни сырья, свойства приёма, раскладка
    файлов DDL и карта таблиц.
  - спеки приведены в соответствие: механизм строгого приёма, имена объектов,
    контракт транспорта «одно событие — одно сообщение Kafka», три проверки
    при исполнении.
- Проверка:
  - make config-test
2026-08-04 00:14:13 +03:00
187 changed files with 20485 additions and 2347 deletions

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
.git/
.agents/
.claude/
.vscode/
.idea/
.env
.env.*
*.pem
*.key
*.crt
secrets/
credentials/
**/__pycache__/
**/*.py[cod]
**/.venv/
**/.pytest_cache/
**/.ruff_cache/
**/.mypy_cache/
**/.ty_cache/
**/*.egg-info/
**/build/
**/dist/
tmp/
logs/
*.log
*.tmp
+17 -9
View File
@@ -1,33 +1,41 @@
# Имя и внешние порты меняют для второй копии стенда или при конфликте.
COMPOSE_PROJECT_NAME=clickstream-data-platform
CLICKHOUSE_IMAGE=clickhouse/clickhouse-server:26.3.17.56
CLICKHOUSE_01_HTTP_PORT=28123
CLICKHOUSE_01_TCP_PORT=29000
CLICKHOUSE_02_HTTP_PORT=28124
CLICKHOUSE_02_TCP_PORT=29001
KAFKA_IMAGE=apache/kafka:4.3.1
CLICKHOUSE_CLUSTER_SECRET=cluster-26-secret
CLICKHOUSE_DEFAULT_PASSWORD=default-26-local
CLICKHOUSE_ETL_PASSWORD=etl-26-writer
CLICKHOUSE_LIFECYCLE_PASSWORD=lifecycle-26-owner
CLICKHOUSE_BI_PASSWORD=bi-26-reader
CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader
CLICKHOUSE_GRAFANA_PASSWORD=grafana-26-reader
KAFKA_EXTERNAL_PORT=29092
PROMETHEUS_IMAGE=prom/prometheus:v3.13.2
PROMETHEUS_PORT=29090
GRAFANA_IMAGE=grafana/grafana:13.1.1
GRAFANA_PORT=23000
AIRFLOW_PORT=28080
SUPERSET_PORT=28088
# GID группы `docker` на этой машине: без него планировщик Airflow не достучится
# до сокета докера, и пульт мира не поднимет контейнер генератора.
# Подсмотреть свой — `getent group docker`.
DOCKER_GID=127
# Учётные данные и ключи учебного стенда. Для VPS замените значения образца.
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
POSTGRES_IMAGE=postgres:16-alpine
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres-local
AIRFLOW_METADATA_USER=airflow
AIRFLOW_METADATA_PASSWORD=airflow-local
SUPERSET_METADATA_USER=superset
SUPERSET_METADATA_PASSWORD=superset-local
AIRFLOW_IMAGE=apache/airflow:3.3.0
AIRFLOW_PORT=28080
AIRFLOW_ADMIN_USER=admin
AIRFLOW_ADMIN_PASSWORD=airflow
AIRFLOW_JWT_SECRET=local-only-airflow-jwt-secret-do-not-use
AIRFLOW_API_SECRET_KEY=local-only-airflow-api-secret-do-not-use
AIRFLOW_FERNET_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
SUPERSET_IMAGE=apache/superset:6.1.0
SUPERSET_PORT=28088
SUPERSET_ADMIN_USER=admin
SUPERSET_ADMIN_PASSWORD=superset
SUPERSET_SECRET_KEY=local-only-superset-secret-do-not-use-outside-this-stand
+4
View File
@@ -31,6 +31,7 @@ credentials/
# Логи и временные файлы
logs/
tmp/
*.log
*.tmp
*.bak
@@ -43,3 +44,6 @@ Thumbs.db
# Локальные настройки Claude Code
.claude/*
!.claude/agents/
# Локальные рабочие файлы агентов: хэндоффы, обменные артефакты ревью
.scratch/
+47 -1
View File
@@ -8,6 +8,31 @@
Стенд строится по спеке
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
Отсюда вопрос к любой доработке — коду, проверке, документу: **чему на ней
научится менти?** Ответ называется одной фразой до того, как писать. Не
складывается фраза — это вопрос владельцу, а не строчка кода: проверки в этом
репозитории однажды уже переросли продукт.
**Усложнение не бесплатно, и платит за него учебная ценность.** Чем сложнее
стенд, тем хуже он как учебный материал: внимание менти конечно, и каждая
конструкция, которую он обязан расшифровать по дороге к уроку, списывается с
этого счёта. Поэтому вопрос выше имеет вторую половину: **что менти платит,
чтобы это прочесть, и что получает взамен?** Ответ «вопрос — и ничего»
означает, что писать не нужно.
Оборонительный код дороже прочего. Читатель не отличает «стоит здесь, потому
что есть настоящая опасность» от «стоит здесь, потому что кто-то был
остроумен», — и предполагает первое. Лишний сторож не безобиден: он
утверждает, будто опасность заслуживает внимания, и этим врёт. Особенно это
касается защиты от ошибки, которую человек делает сам себе и тут же видит:
разбирательство с ней и есть урок, отнимать его не надо.
Ревью само по себе умеет только прибавлять: каждая находка просится в правку,
и за прогон их набираются десятки. Поэтому перед приёмкой заметной работы
идёт отдельный **проход на вычитание** — с правом только резать и с явным
списком неприкосновенного. Сомнение «резать или нет» решается в пользу
реза: вещь, которая себя не защитила, себя не защитила.
## Кому что поручать
Читатель здесь не побочный потребитель, а тот, ради кого стенд существует:
@@ -52,8 +77,18 @@ Airflow) и названия из кода. Если для понятия ес
## Код и данные
- Python — только через `uv`; проверка и формат — `ruff` (`make lint`).
- Python — только через `uv`; проверка и формат — `ruff`. Цель `make lint`
есть за двумя дверями и охватывает разное: в корне — код стенда, в
`generator/` — код генератора. Правишь даги или Superset — зови корневую,
правишь генератор — зови из `generator/`.
- Изменения держать минимальными и в границах задания.
- `make clean` полностью удаляет постоянное состояние стенда. Перед этой
командой назови состояние вне прикладного мира, которое требуется удалить,
и объясни, почему `make rebuild-storage` его не удаляет. При изменении DDL
или проверке на чистых данных по умолчанию используй адресную очистку либо
`make rebuild-storage`.
- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук
обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md).
- Секреты не коммитить: настройки — через `.env`, образец — `.env.example`.
- При изменении инфраструктуры или DDL обновлять документацию тем же PR.
- Коммиты — Conventional Commits: заголовок `type(scope): результат`, тело на
@@ -122,6 +157,17 @@ Airflow) и названия из кода. Если для понятия ес
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
истории, а текущее описание живого формата — и собирается из кода, а не
пишется руками.
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
в имени не нужна.
- Имена файлов в `docs/architecture/` — слаг строчными латинскими буквами через
дефис (`storage.md`). Здесь живут рабочие справочники по зонам
ответственности: не событие истории и не решение, а текущее устройство —
один файл на зону, правится по мере постройки. Крупный компонент живёт
подпапкой: индекс `README.md` и файл на каждую часть устройства
(`architecture/orders/`), имена по тому же правилу
([ADR 0011](docs/adr/0011-component-docs.md)).
+84 -4
View File
@@ -34,6 +34,20 @@ _Избегать_: состояние мира
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
активности. Единица плана состава: когорта — функция зерна и номера дня.
**Человек**:
Устойчивая личность в составе мира, которой принадлежат одна или две куки.
Не поле источника: кликстрим видит куку, а заказ — пользователя магазина.
**Посетитель**:
Кука, наблюдаемая в кликстриме и обозначенная `ClientID`; единица
`uniq(ClientID)`. Один человек может быть представлен несколькими посетителями.
_Избегать_: «посетитель» про человека после склейки
**Пользователь магазина**:
Представление человека со стороны заказов; в записи заказа обозначается
`user_id`. Через заказы связывает разные куки человека, в кликстрим напрямую
не попадает.
**Приток**:
Появление новых кук на всём протяжении оси модельного времени; единица —
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
@@ -80,8 +94,15 @@ _Избегать_: «покупатель» про того, кто купил
страницы. Садится на ту страницу, где случилось: корзина — на карточку
товара, покупка — на страницу подтверждения заказа.
**Справочник**:
Данные, которые хранилище не производит, а получает готовыми со стороны:
содержимым владеет источник вне хранилища, слои только читают. Живут в базе
`dic`. Первый и пока единственный — каталог товаров.
_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и
объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен.
**Каталог товаров**:
`data/catalog/products.csv` — общий справочник генератора и словаря
`data/catalog/products.csv` — общий файл генератора и словаря
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
**Уровень спроса**:
@@ -95,18 +116,38 @@ ClickHouse. Форма файла решена, длина — нет: стро
первого дня D0; реальный календарь в модели не участвует. Между прогонами
живут только зерно и позиция на оси.
**Стартовый мир**:
Первые восемь дней оси (понедельник по понедельник), которыми стенд
наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
на нем принимаются следующие этапы.
_Избегать_: зерновой мир
**Опись мира**:
`data/world-inventory.json` — единственное, что о мире хранится в git: паспорт
мира (зерно, версия генератора, хеш каталога), строка на каждый день событий
(число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов).
Счётчик сверх этого заводится только с названным читателем. Сам мир не
хранится, а пересчитывается; опись отвечает на один вопрос: тот ли это мир.
_Избегать_: манифест, мини-манифест
**Пошаговый режим**:
Базовый способ движения по оси модельного времени: «прожить следующий
день» — явное действие.
**Живой день**:
Проигрывание текущего модельного дня в реальном времени с ускорением;
включается по требованию, не постоянный фон.
включается по требованию — постоянным фоном идёт, только пока включён
выключатель пульта.
**Пакетный режим**:
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
стенда, пересборки и проверки.
**Пульт мира**:
Даги Airflow, которыми двигают ось модельного времени: работники играют день —
пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
_Избегать_: управляющие даги
**Граница суток**:
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
снимается на ней, день проживается только целиком.
@@ -128,9 +169,48 @@ Python-модуль с описателями колонок события —
_Избегать_: описание схемы, документация контракта
**Канонический сериализатор**:
Единственное место, где событие превращается в байты. Фиксированный порядок
ключей и строк — основа побайтовой воспроизводимости.
Единственная граница, где запись генератора превращается в байты. Фиксированный
порядок ключей и строк — основа побайтовой воспроизводимости.
**Проигрыватель**:
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
(пачкой, без темпа) и живой день.
**Слепок**:
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
состояние заказов на этот момент, а не поток их изменений. Опоздавший заказ
может отсутствовать в ранних слепках; после первого появления ездит до конца
своего окна.
**Дата слепка**:
Дата завершившегося модельного дня, состояние которого снято на исходящей
границе суток. Одна для всех записей выгрузки; не дата заказа и не время
отправки сообщения.
**Время аудита источника**:
Время создания или последнего изменения строки заказа по часам базы источника.
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в
событии `purchase` отдельно.
**Судьба заказа**:
Исход, моменты изменений, вычеркнутая позиция и опоздание заказа — всё
решается бросками при его рождении и укладывается в окно изменяемости целиком.
Слепок любого дня — чтение готовой судьбы, а не накопление состояния.
**Окно изменяемости**:
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
в слепках. Константа мира: семь дней. За окном заказ замёрз, выручка дня
перестала «дышать».
**Пакетный забор**:
Способ приёма топика, при котором данные тянет запрос по команде, а не
матвью непрерывно. Так стенд принимает заказы: у слепка есть начало и конец,
и забирать его уместно тогда, когда он собран целиком. Противоположность —
потоковый приём событий.
_Избегать_: путать с приёмником и пакетным режимом — те про сторону
генератора, про отправку; забор — про сторону хранилища, про чтение.
**Техническая проверка**:
Проверка, чей оракул — внутренний эталон стенда (опись мира), которого у
настоящего магазина нет. За учебный урок DQ не выдаётся.
_Избегать_: DQ-проверка — та работает на наблюдаемых данных магазина.
+44 -23
View File
@@ -1,45 +1,66 @@
COMPOSE ?= docker compose
GENERATOR_DAY ?= 0
GENERATOR_LIMIT ?=
GENERATOR_SPEED ?=
.PHONY: up down clean ps logs config-test lint typecheck test docs smoke smoke-cluster smoke-guards
# Цели корня — про стенд; проверки генератора — в `generator/Makefile`.
.PHONY: up down clean rebuild-storage ps logs smoke check-clickhouse check-services config-test lint generate-batch generate-live
# --- Жизнь стенда ---
# Compose дожидается инфраструктуры, Airflow — готового прикладного мира.
up:
$(COMPOSE) up --detach --build --wait --wait-timeout 600
$(COMPOSE) up --detach --build --wait --wait-timeout 600 --remove-orphans
COMPOSE_BIN="$(COMPOSE)" ./scripts/run-dag.sh world_initialize
down:
$(COMPOSE) down --remove-orphans
# Полное удаление стенда, а не способ получить чистые данные перед проверкой.
# Граница операции и требование к обоснованию — в AGENTS.md.
clean:
$(COMPOSE) down --volumes --remove-orphans
# Возврат прикладного мира к началу без удаления инфраструктурных томов.
rebuild-storage:
COMPOSE_BIN="$(COMPOSE)" ./scripts/run-dag.sh world_recreate
ps:
$(COMPOSE) ps
logs:
$(COMPOSE) logs --follow
config-test:
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
./tests/stand-smoke-static.sh
lint:
cd generator && uv run ruff check && uv run ruff format --check
typecheck:
cd generator && uv run ty check
test:
cd generator && uv run pytest
docs:
cd generator && uv run python -m clickstream_generator.schema_doc \
../docs/formats/clickstream-event.md
# --- Проверки, которым нужен поднятый стенд ---
smoke:
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh
smoke-cluster:
./scripts/clickhouse-smoke.sh
check-clickhouse:
./scripts/check-clickhouse.sh
smoke-guards:
./tests/smoke-guards.sh
COMPOSE_BIN="$(COMPOSE)" ./tests/stand-smoke-guards.sh
check-services:
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-services.sh
# --- Проверки, которым стенд не нужен ---
config-test:
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
# Пути названы вслух: без них ruff из корня прошёлся бы и по генератору, а у
# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне
# нет, а форматтер между версиями меняет вывод — иначе проверка однажды
# покраснела бы сама, без единой правки в репозитории.
lint:
uvx ruff@0.16.1 check dags infra/superset
uvx ruff@0.16.1 format --check dags infra/superset
# --- Наполнение миром ---
generate-batch:
$(COMPOSE) --profile generator run --rm generator batch --day "$(GENERATOR_DAY)" \
$(if $(GENERATOR_LIMIT),--limit "$(GENERATOR_LIMIT)")
generate-live:
$(COMPOSE) --profile generator run --rm generator live --day "$(GENERATOR_DAY)" \
$(if $(GENERATOR_SPEED),--speed "$(GENERATOR_SPEED)")
+300 -86
View File
@@ -9,9 +9,14 @@
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
Сейчас работают кластер ClickHouse из двух шардов, отдельный
clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset,
Prometheus, Grafana и общая база Postgres для метаданных. Начат генератор
кликстрима: в [`generator/`](generator/) заведён контракт схемы события, из
которого собрано [описание выгрузки](docs/formats/clickstream-event.md).
Prometheus, Grafana, экспортёр метрик Kafka и общая база Postgres для
метаданных. Генератор кликстрима в [`generator/`](generator/) собран
целиком со стороны клиента: у него есть контракт схемы события, из которого
собрано [описание выгрузки](docs/formats/clickstream-event.md), модельный мир и
проигрыватель, отправляющий дни в Kafka или в файл. События доезжают до
типизированного `ods.event`, а `dds.event_v` отдаёт их на языке бизнес-модели
без копии данных. `make up` наполняет стенд стартовым миром — первой неделей
модельного времени.
## Быстрый старт
@@ -23,90 +28,255 @@ Prometheus, Grafana и общая база Postgres для метаданных.
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
хватает, стенд одинаково хорошо живёт на недорогом VPS.
Полная проверка также использует `curl`, `jq`, `awk`,
`grep`, `sed`, `tail`, `sleep` и `timeout`. По умолчанию должны быть свободны
Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`,
`grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им
читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
и `29092`. Проверкам без стенда — `make config-test`, `make lint`,
`make typecheck`, `make test` — и сборке документации `make docs` нужен `uv`.
и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне,
всем целям генератора в `generator/` — нужен `uv`.
Стенд запускается без `.env`:
```bash
make up
make smoke
```
Чтобы изменить образы, порты или учебные учётные данные, скопируйте образец:
Сначала скопируйте настройки стенда:
```bash
cp .env.example .env
make up
make smoke
make check-clickhouse
make check-services
```
Первому знакомству нужны все три проверки на стенде: `make smoke` говорит, что
стенд собран, `make check-clickhouse` — что кластер работает кластером, а
`make check-services` — что Airflow запускает DAG, а Superset ходит в базу.
Дальше, в рабочей петле, обычно хватает `make smoke`.
`.env.example` — образец обязательной локальной настройки. В нём живут имя
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
кроме пульта мира — о нём ниже. `compose.yaml` не
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
Учётные данные Postgres и Grafana применяются при создании их томов.
После первого запуска меняйте их только вместе с `make clean`: команда удалит
все локальные данные стенда, а следующий `make up` создаст их с новыми
значениями.
`make up` собирает локальные образы Airflow и Superset, поднимает весь стенд и
ждёт здорового состояния долгоживущих контейнеров. В образ Airflow добавлены
закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и
`make up` собирает локальные образы Airflow, Superset и Grafana, поднимает весь
стенд и ждёт здорового состояния долгоживущих контейнеров. В образ Airflow
добавлены закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет
Superset, создаёт администратора и импортирует подключение к `clickhouse-02`.
подготавливает администратора и два подключения к `clickhouse-01`. Второй
обновляет Superset, создаёт администратора и импортирует подключение к
`clickhouse-02`. На ноутбуке в режиме энергосбережения приёмочный подъём с
пустых томов занял около десяти минут, повторный на живом стенде — около трёх.
`make smoke` проверяет согласованность `.env.example` с Compose, зависимости
машины, здоровье контейнеров, Kafka через порт машины, три цели Prometheus,
источник Grafana, компоненты Airflow, ручной запуск пробников `test_clickhouse`
и `test_kafka`, метаданные и подключение Superset. Первый пробник создаёт
таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из
локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. В конце
проверка спрашивает у Docker, не убивало ли ядро что-нибудь в долгоживущих
контейнерах за нехватку памяти и не включалась ли политика перезапуска: убитый
контейнер Docker поднимает сам, и проверка состояния об этом промолчит.
Временный топик проверки с машины и запуски DAG удаляются;
`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
здоровье контейнеров, устройство keeper, ответ Kafka с машины через отображённый
порт, четыре цели Prometheus, два источника Grafana, компоненты Airflow и
подготовленное подключение к `clickhouse-01`. В конце проверка спрашивает у
Docker, не убивало ли ядро что-нибудь в долгоживущих контейнерах за нехватку
памяти и не включалась ли политика перезапуска: убитый контейнер Docker
поднимает сам, и проверка состояния об этом промолчит.
Двенадцать проверок здоровья сразу после `make up --wait` повторяют то, чего
Compose уже дождался: у каждой долгоживущей службы есть своя `healthcheck`.
Оставлены они потому, что первый вопрос к стенду всё равно «всё ли живо», а
ответ на него стоит меньше секунды. Устройство keeper — другое дело: он должен
работать от пользователя `clickhouse`, с пределом в 262144 открытых файла и со
своим томом под данные. Всё это объявлено в `compose.yaml`, но здоровым keeper
выглядит и без этого, поэтому смоук спрашивает у живого контейнера, дошли ли
объявленные настройки до процесса.
Kafka по той же причине спрашивают снаружи. Её `healthcheck` обращается к
брокеру изнутри контейнера и по внутреннему слушателю, поэтому здоровой Kafka
остаётся и тогда, когда объявленный наружу адрес ведёт не туда. Клиент с машины
в этом случае подключается, получает метаданные и молча виснет на адресе,
которого с его стороны не существует. Смоук идёт тем же путём, что и будущий
генератор: стучится в отображённый порт и просит список топиков.
`make check-services` проверяет то, ради чего приходится ждать службу: работу с
топиком Kafka с машины — создан, найден, удалён, — ручной запуск пробников
`test_clickhouse` и `test_kafka`, вход в Superset, его метаданные и подключение
к `clickhouse-02`. Первый пробник создаёт таблицы на обеих нодах и читает через
`Distributed` на ноде 2 строку из локальной таблицы ноды 1. Второй пишет в Kafka
и читает свой маркер. Временный топик проверки с машины и запуски DAG удаляются;
постоянный топик пробника сохраняется, а старые записи чистит Kafka.
`make smoke-cluster` запускает отдельную глубокую проверку ClickHouse: описание
кластера, макросы, связь с keeper, `ReplicatedMergeTree`, `Distributed`, очередь
распределённых DDL и очистку временных таблиц.
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая
из десяти проверок спрашивает словарь товаров на обеих нодах. Последняя —
единственная на настоящих событиях: она подневно сверяет стартовый мир с описью
и при расхождении говорит, где искать — в событиях или в браке.
`make config-test` проверяет Compose, синтаксис Bash и Python, малые проверки
логики пробников и пробельные ошибки в diff без запуска стенда.
`make config-test` проверяет Compose, синтаксис файлов DAG и пробельные ошибки
в diff без запуска стенда.
`make smoke-guards` сначала проверяет аварийную семантику кластерной проверки,
а затем удаляет служебную таблицу пробника только на второй ноде и
останавливает Prometheus с Kafka. Общая проверка должна назвать Prometheus и
оба пробника, после чего завершиться с ошибкой. В конце стенд восстанавливается.
Обычный рабочий цикл — `make config-test` и `make smoke`; остальные цели гоняют
тогда, когда правка их касается. Что утверждает каждая проверка, нужен ли ей
поднятый стенд, сколько стоит прогон и куда класть новую — в
[карте проверок](docs/architecture/testing.md).
### Какую проверку когда запускать
Остановить контейнеры без удаления данных можно командой `make down`. Если
нужно вернуть прикладной мир к началу, `make rebuild-storage` запустит в
Airflow его явную пересборку: удалит прикладные базы ClickHouse, топики и
позиции потребителей Kafka, затем создаст схему и данные заново. Службы и их
тома останутся на месте. `make clean` удаляет весь стенд вместе с именованными
томами и для обычной разработки не нужен. Перед пересборкой поставьте
`world_live` на паузу и дайте уже начатому работнику закончить день.
Проверки выстроены лесенкой: чем дороже прогон, тем больше связей он трогает.
## Стартовый мир
- `make config-test` — секунды, стенд поднимать не нужно. Видит только то, что
есть в файлах, и о работоспособности не говорит ничего. Дёшево настолько, что
можно гонять перед каждым коммитом.
- `make lint` и `make test` — тоже секунды и тоже без стенда, но про другой
код: ruff и тесты генератора в `generator/`. Тесты сторожат контракт схемы
события и свежесть собранного из него описания выгрузки.
- `make smoke` — минута-две на поднятом стенде. Дороже, но проверяет связи
между службами, а не отдельные файлы: это интеграционная проверка.
- `make smoke-cluster` — около минуты. Одна связь, зато до дна: межнодовое
устройство ClickHouse.
- `make smoke-guards` — около пяти минут, и отвечает на другой вопрос. Не
«работает ли стенд», а «умеют ли проверки падать»: она намеренно ломает стенд
и смотрит, покраснеет ли `make smoke` и назовёт ли виновника, потом чинит и
убеждается, что стенд снова зелёный. Отсюда и три прогона `make smoke`
внутри — до поломки, во время неё и после починки.
Стенд поднимается не пустым: `make up` через REST API запускает даг Airflow
`world_initialize`. Тот создаёт прикладные топики и схему, собирает образ
генератора и играет в `hits` первые восемь дней модельного времени — понедельник
по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в
`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только
тогда ставит позицию мира в `8`. Так у всякой лабы есть данные, и всегда одни и
те же.
Обычный рабочий цикл — `make config-test` и `make smoke`. `make smoke-guards`
нужна тому, кто правит сами проверки или пробники: без неё легко завести
проверку, которая зелена всегда.
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
виден разный темп потокового трекера и ночной выгрузки магазина.
Остановить контейнеры без удаления данных можно командой `make down`. Для
полного сброса с удалением всех именованных томов используйте `make clean`.
Повторный `make up` безопасен: одноразовая подготовка приложений идемпотентна.
Повторный `make up` безопасен: `world_initialize` видит готовую позицию,
сообщает в журнале, что мир уже существует, и не применяет DDL, не собирает
образ и не заливает данные повторно. Изменённую схему применяют адресно либо
явной командой `make rebuild-storage`.
Сам мир в git не хранится — он чистая функция зерна, и держать его в
репозитории значило бы держать там кэш. Вместо него лежит **опись мира**,
[`data/world-inventory.json`](data/world-inventory.json): зерно, версия
генератора, хеш каталога товаров и по строке на каждый день — дата, число
событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики
классов расхождений по заказам. Опись отвечает на единственный вопрос: тот ли
это мир, что был вчера.
Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что
собирается из кода сегодня: правка генератора меняет мир, и опись надо
пересобрать — `make inventory` там же. `make check-clickhouse` в корне сверяет
с описью то, что доехало до `ods.event`, подневно. Хеш дня можно пересчитать и
руками — это обычный `sha256sum` файла, который пишет файловый приёмник:
```bash
uv run --project generator python -m clickstream_generator batch \
--day 0 --file tmp/day0.jsonl
sha256sum tmp/day0.jsonl
```
## Как позвать генератор
События производит генератор из [`generator/`](generator/). Модельный день —
функция зерна и номера дня, поэтому один и тот же день всегда даёт те же
события: обрыв лечится повтором. Позицию на оси генератор не помнит — какой
день играть, решает зовущий.
Режима два, и различаются они темпом. **Пакетный** гонит день подряд, без пауз:
так заливается мир и так переигрывается день после обрыва. **Живой** держит темп
модельного времени — по умолчанию ×60: модельные сутки за 24 реальные минуты,
суточная волна разворачивается на глазах, отставание видно в логе.
На стенде генератор ходит своим образом — разовой службой, которую поднимают
и убирают на один прогон. На каждый режим по цели:
```bash
# день D0 целиком в топик hits
make generate-batch
# первая сотня событий дня D3
make generate-batch GENERATOR_DAY=3 GENERATOR_LIMIT=100
# день D3 живьём, ускорение ×1000
make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
```
| Переменная | Что задаёт | Умолчание |
| --- | --- | --- |
| `GENERATOR_DAY` | номер дня на оси мира (D0 — первый) | `0`, задано в `Makefile` |
| `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком |
| `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе |
Куда отправлять, обеим целям задаёт сам стенд: внутри сети Compose это брокер
`kafka:9092` и топик `hits`.
Образ цели не пересобирают: нет образа — Compose соберёт его сам, есть —
возьмёт как есть. Пересобрать намеренно, после правки `generator/Dockerfile`
или зависимостей, — отдельной командой:
```bash
docker compose --profile generator build generator
```
Тот же образ несёт заливка стартового мира. Перед такой заливкой
`world_initialize` сам собирает его из текущего дерева; на готовом мире
повторный `make up` до сборки не доходит.
Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и
цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ
устарел, видно по собственному прогону — это обратная связь, а не ловушка.
Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно
событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня —
тому, кто смотрит на конвейер, ждать полсотни тысяч событий незачем.
```bash
uv run --project generator python -m clickstream_generator batch \
--day 0 --limit 100 --file tmp/day0.jsonl
```
Несколько дней подряд играет один запуск: `--days 8`. Всё, что принимается
флагами, принимается и переменными окружения — так генератор позовёт даг
этапа 5; полный список у `--help`.
**Умолчания есть не у всего, и это нарочно.** Зерно, число дней и темп живого
дня описывают модель мира — их генератор знает сам. День на оси, приёмник и имя
топика описывают стенд, на котором его запустили: не назвали — отказ и ненулевой
код возврата, ещё до первого события. Поэтому умолчание дня и живёт в
`Makefile`: день называет тот, кто запускает, а не тот, кого запускают.
### Пульт мира
Считать дни самому необязательно. В Airflow живёт **пульт мира**: два дага
создают его начальное состояние, а три двигают ось дальше.
| Даг | Что делает |
| --- | --- |
| `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет |
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
Работник не только играет день: следом он отправляет слепок заказов за
сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший
слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины
стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на
месте ([ADR 0008](docs/adr/0008-order-ingestion.md)).
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон
позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая
переменная означает, что мир ещё не создавали; `-1` — очистка не закончена,
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники
начинают играть только с позиции `8` и больше.
В форме запуска можно выбрать только число дней. Первый день работник берет из
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
позицию работник пишет в журнал шага `remember_played`.
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
целиком — [ADR 0009](docs/adr/0009-world-control.md).
**Плата названа вслух: планировщику Airflow отдан сокет докера** — иначе
контейнер генератора ему не поднять. Доступ к сокету равен праву root на
машине; для локального учебного стенда размен принят, но знать о нём надо.
Открывает дверь не монтирование, а членство в группе: GID группы `docker` у
каждой машины свой, живёт в `.env` и подсматривается командой
`getent group docker`.
## Состав и доступ
@@ -114,14 +284,15 @@ Superset, создаёт администратора и импортирует
- `clickhouse-02` — точка подключения Superset;
- `clickhouse-keeper` — координатор кластера;
- `kafka` — один брокер KRaft;
- `kafka-exporter` — экспортёр метрик Kafka для Prometheus;
- `postgres-metadata` — один Postgres с отдельными базами и пользователями
`airflow` и `superset`;
- `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` —
Airflow 3.3 с LocalExecutor, без triggerer;
- `superset` — интерфейс и подготовленное подключение ClickHouse;
- `prometheus` и `grafana` — сбор и просмотр встроенных метрик ClickHouse.
- `prometheus` и `grafana` — сбор и просмотр метрик ClickHouse и Kafka.
Порты доступны только с локальной машины:
С настройками из `.env.example` порты доступны только с локальной машины:
- нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
- нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
@@ -131,16 +302,48 @@ Superset, создаёт администратора и импортирует
- Prometheus — `http://127.0.0.1:29090`;
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
У локального учебного кластера нет пароля: ноды используют общего пользователя
`default` для запросов `Distributed`. Порты поэтому привязаны к `127.0.0.1` и
не открыты во внешнюю сеть.
В ClickHouse семь пользователей и шесть ролей:
Пароли интерфейсов, пароли Postgres, ключи Airflow и Superset, отсутствие
пароля ClickHouse и отсутствие проверки доступа у Kafka и Prometheus —
намеренно простые и явно ненастоящие настройки локального учебного стенда. Это
не пример настройки защиты: не копируйте значения из `.env.example` в рабочую
среду. Все опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу
не опубликован.
- `etl` подключает рабочие даги Airflow; роль `etl_writer` читает и пишет слои
хранилища;
- `lifecycle` применяет канонический DDL и пересоздаёт прикладные базы; роль
`lifecycle_owner` не даёт эти административные права рабочим дагам;
- `bi` подключает Superset; роль `bi_reader` читает ODS, DDS, DM и справочники
`dic`, а запросы Superset обращаются к публичным `_v`;
- `analyst` предназначен для подключения человека и читает все слои и
справочники;
- `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои,
системные таблицы и данные со всех реплик кластера;
- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит
за своей подложкой;
- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри
контейнеров, но не приложения.
Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в
[ADR 0007](docs/adr/0007-clickhouse-access.md).
Пользователи и роли объявлены в `infra/clickhouse/users.d/access.xml`. Пароли и
общий секрет нод живут в `.env` и передаются в конфигурацию через окружение;
Superset получает пароль `bi`, а Grafana — пароль `grafana` тем же путём.
Значения для локального стенда есть в `.env.example`.
Изменения значений ClickHouse в `.env` и файлов настройки серверов в
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/` применяются
пересозданием нод:
```bash
docker compose up --force-recreate --wait clickhouse-01 clickhouse-02
```
Команда возвращает управление, когда обе ноды снова здоровы; именованные тома
при этом сохраняются. `docker compose restart` оставит прежнее окружение и
может оставить старую версию отдельно смонтированного файла.
Пароли ClickHouse и интерфейсов, пароли Postgres, ключи Airflow и Superset,
отсутствие проверки доступа у Kafka и Prometheus — намеренно простые и явно
ненастоящие настройки локального учебного стенда. Это не пример настройки
защиты: не копируйте значения из `.env.example` в рабочую среду. Все
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
опубликован.
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
@@ -162,24 +365,28 @@ Superset, создаёт администратора и импортирует
серверы и keeper используют один образ. Настройки Kafka 4.3.1 сверены с
[примером односерверного KRaft](https://github.com/apache/kafka/blob/4.3.1/docker/examples/docker-compose-files/single-node/plaintext/docker-compose.yml).
Секция метрик взята из конфигурации закреплённого образа ClickHouse и проверена
на серверах и keeper. Подготовка источника Grafana сверена с
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/).
Prometheus собирает только встроенные метрики двух серверов и keeper; внешних
сборщиков, панелей и правил оповещения пока нет.
После изменения `infra/clickhouse/config.d/prometheus.xml` выполните
`docker compose restart clickhouse-01 clickhouse-02`: обычный `make up` не
перезапускает уже созданные серверы и они продолжают работать со старой
конфигурацией.
на серверах и keeper. Подготовка источников Grafana сверена с
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/)
и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/).
Prometheus собирает встроенные метрики двух серверов и keeper, а через
`kafka-exporter` — состояние Kafka. Поверх этого в Grafana поднимается дашборд
«Данные»: свежесть, поток, брак, сходимость Kafka с ClickHouse, отставание
чтения и дневной слепок заказов. Устройство зоны — в [справочнике
мониторинга](docs/architecture/monitoring/README.md), разбор панелей — в
[описании дашборда](docs/architecture/monitoring/data.md); правил оповещения
пока нет.
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor,
публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с
[архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html),
[публичным интерфейсом](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html)
и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html).
Для пробников проверены публичный `Connection.get` из `airflow.sdk` и клиент
Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет
общий `SQLExecuteQueryOperator`, программные запросы идут через
`ClickHouseHook`. Провайдер работает поверх закреплённого
`clickhouse-connect`. Официальный провайдер Kafka сам использует
`confluent-kafka`; отдельное подключение и обёртки провайдера здесь не нужны,
поэтому прямой клиент оставляет образ и пример короче.
`confluent-kafka`; отдельное подключение и его обёртки здесь не нужны, поэтому
клиент Kafka добавлен в образ напрямую.
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма
`clickhousedb://` и драйвер Postgres сверены с
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
@@ -204,9 +411,16 @@ Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, ф
## Документация
- [docs/specs/](docs/specs/) — спеки: источник истины о задуманном.
- [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми
вариантами: почему сделано так, а не иначе.
- [docs/architecture/](docs/architecture/) — рабочие справочники по зонам
ответственности: [хранилище](docs/architecture/storage.md) — конвенции имён,
раскладка по шардам, приём событий, карта таблиц;
[проверки](docs/architecture/testing.md) — что утверждает каждая цель `make`
и куда класть новую проверку.
- [docs/research/](docs/research/) — исследования; сейчас это формат
кликстрима Яндекса, по которому строится модель события.
- [docs/formats/](docs/formats/) — описания форматов источников: по ним
пишется сторона хранилища. Собираются из кода командой `make docs`, руками
не правятся.
пишется сторона хранилища. Собираются из кода командой `make docs` в
`generator/`, руками не правятся.
- [AGENTS.md](AGENTS.md) — контракт работы в репозитории.
+154 -49
View File
@@ -1,4 +1,4 @@
name: ${COMPOSE_PROJECT_NAME:-clickstream-data-platform}
name: ${COMPOSE_PROJECT_NAME:?Скопируйте .env.example в .env}
x-clickhouse-nofile: &clickhouse-nofile
nofile:
@@ -6,11 +6,21 @@ x-clickhouse-nofile: &clickhouse-nofile
hard: 262144
x-clickhouse-common: &clickhouse-common
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
image: clickhouse/clickhouse-server:26.3.17.56
restart: unless-stopped
environment:
# Стенд локальный: нодам нужен общий пользователь без пароля для Distributed.
# Пользователей объявляет access.xml; настройка учётки образом не нужна.
CLICKHOUSE_SKIP_USER_SETUP: "1"
# Каталог товаров смонтирован в user_files только для чтения. Entrypoint
# иначе пытается сделать ему chown и первый запуск ноды завершается ошибкой.
CLICKHOUSE_DO_NOT_CHOWN: "1"
CLICKHOUSE_CLUSTER_SECRET: ${CLICKHOUSE_CLUSTER_SECRET:?Скопируйте .env.example в .env}
CLICKHOUSE_DEFAULT_PASSWORD: ${CLICKHOUSE_DEFAULT_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env}
depends_on:
clickhouse-keeper:
condition: service_healthy
@@ -18,52 +28,90 @@ x-clickhouse-common: &clickhouse-common
# Гигабайта не хватало: половину съедали страницы самого бинарника (ADR 0004).
mem_limit: 4g
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --host 127.0.0.1 --query 'SELECT 1' >/dev/null 2>&1"]
test: ["CMD-SHELL", "clickhouse-client --host 127.0.0.1 --user default --password \"$$CLICKHOUSE_DEFAULT_PASSWORD\" --query 'SELECT 1' >/dev/null 2>&1"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
# Факты генератора и пульта мира названы по одному разу — иначе они однажды
# разойдутся, и заметит это не проверка, а менти с пустым топиком.
x-generator-image: &generator-image clickstream-generator:local
x-orders-topic: &orders-topic orders
x-kafka-common: &kafka-common
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
x-kafka-target: &kafka-target
<<: *kafka-common
KAFKA_TOPIC: hits
x-world-starting-days: &world-starting-days "8"
# Общие образы Airflow и Superset собирают службы инициализации. Причина
# отдельного build: ADR 0001.
x-airflow-common: &airflow-common
image: clickstream-airflow:local
build:
context: ./infra/airflow
args:
AIRFLOW_BASE_IMAGE: ${AIRFLOW_IMAGE:-apache/airflow:3.3.0}
environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__PARALLELISM: "4"
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
AIRFLOW__CORE__AUTH_MANAGER: airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}:admin"
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/auth/simple_auth_manager_passwords.json
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-local-only-airflow-jwt-secret-do-not-use}
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:?Скопируйте .env.example в .env}
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:?Скопируйте .env.example в .env}
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-local-only-airflow-api-secret-do-not-use}
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: "postgresql+psycopg2://${AIRFLOW_METADATA_USER:-airflow}:${AIRFLOW_METADATA_PASSWORD:-airflow-local}@postgres-metadata:5432/airflow"
AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:?Скопируйте .env.example в .env}
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: "postgresql+psycopg2://${AIRFLOW_METADATA_USER:?Скопируйте .env.example в .env}:${AIRFLOW_METADATA_PASSWORD:?Скопируйте .env.example в .env}@postgres-metadata:5432/airflow"
AIRFLOW__SCHEDULER__ENABLE_HEALTH_CHECK: "true"
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:-admin}
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-airflow}
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
# Пульт мира поднимает генератор отдельным контейнером. Имя сети собирается
# из имени проекта: у второй копии стенда оно другое.
<<: *kafka-target
GENERATOR_IMAGE: *generator-image
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
WORLD_STARTING_DAYS: *world-starting-days
KAFKA_ORDERS_TOPIC: *orders-topic
volumes:
- ./dags:/opt/airflow/dags:ro
# Запросы дагов: даг называет файл, а обработчик DAG при разборе загружает
# его текст. Каталог тот же, что применяет DDL, — вся SQL стенда живёт в sql/.
- ./sql:/opt/airflow/sql:ro
# Инициализация мира собирает канонический образ из того же контекста, что
# ручной generator. Docker SDK архивирует эти файлы внутри задачи.
- ./generator:/opt/airflow/world/generator:ro
- ./data:/opt/airflow/world/data:ro
- ./.dockerignore:/opt/airflow/world/.dockerignore:ro
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
- airflow_logs:/opt/airflow/logs
- airflow_auth:/opt/airflow/auth
x-generator-common: &generator-common
image: *generator-image
build:
context: .
dockerfile: generator/Dockerfile
restart: "no"
environment:
# Адрес брокера и имя топика — факты стенда, и называет их стенд.
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами
# от того, кто запускает: у службы нет позиции на оси мира.
<<: *kafka-target
x-superset-common: &superset-common
image: clickstream-superset:local
build:
context: ./infra/superset
args:
SUPERSET_BASE_IMAGE: ${SUPERSET_IMAGE:-apache/superset:6.1.0}
environment:
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-local-only-superset-secret-do-not-use-outside-this-stand}
SUPERSET__SQLALCHEMY_DATABASE_URI: "postgresql+psycopg2://${SUPERSET_METADATA_USER:-superset}:${SUPERSET_METADATA_PASSWORD:-superset-local}@postgres-metadata:5432/superset"
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:-superset}
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
SUPERSET__SQLALCHEMY_DATABASE_URI: "postgresql+psycopg2://${SUPERSET_METADATA_USER:?Скопируйте .env.example в .env}:${SUPERSET_METADATA_PASSWORD:?Скопируйте .env.example в .env}@postgres-metadata:5432/superset"
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:?Скопируйте .env.example в .env}
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
volumes:
- ./infra/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
- ./infra/superset/init.sh:/app/infra/init.sh:ro
@@ -72,7 +120,7 @@ x-superset-common: &superset-common
services:
clickhouse-keeper:
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
image: clickhouse/clickhouse-server:26.3.17.56
restart: unless-stopped
user: clickhouse
ulimits: *clickhouse-nofile
@@ -91,42 +139,48 @@ services:
retries: 30
start_period: 10s
# Инициатор DDL и будущее подключение Airflow.
# Инициатор DDL и точка подключения Airflow.
clickhouse-01:
<<: *clickhouse-common
hostname: clickhouse-01
ports:
- "127.0.0.1:${CLICKHOUSE_01_HTTP_PORT:-28123}:8123"
- "127.0.0.1:${CLICKHOUSE_01_TCP_PORT:-29000}:9000"
- "127.0.0.1:${CLICKHOUSE_01_HTTP_PORT:?Скопируйте .env.example в .env}:8123"
- "127.0.0.1:${CLICKHOUSE_01_TCP_PORT:?Скопируйте .env.example в .env}:9000"
volumes:
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
- ./infra/clickhouse/config.d/macros-01.xml:/etc/clickhouse-server/config.d/macros.xml:ro
- ./infra/clickhouse/users.d/access.xml:/etc/clickhouse-server/users.d/access.xml:ro
- ./data/catalog:/var/lib/clickhouse/user_files/catalog:ro
- clickhouse_01_data:/var/lib/clickhouse
# Будущее подключение Superset.
# Точка подключения Superset.
clickhouse-02:
<<: *clickhouse-common
hostname: clickhouse-02
ports:
- "127.0.0.1:${CLICKHOUSE_02_HTTP_PORT:-28124}:8123"
- "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:-29001}:9000"
- "127.0.0.1:${CLICKHOUSE_02_HTTP_PORT:?Скопируйте .env.example в .env}:8123"
- "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:?Скопируйте .env.example в .env}:9000"
volumes:
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
- ./infra/clickhouse/config.d/macros-02.xml:/etc/clickhouse-server/config.d/macros.xml:ro
- ./infra/clickhouse/users.d/access.xml:/etc/clickhouse-server/users.d/access.xml:ro
- ./data/catalog:/var/lib/clickhouse/user_files/catalog:ro
- clickhouse_02_data:/var/lib/clickhouse
kafka:
image: ${KAFKA_IMAGE:-apache/kafka:4.3.1}
image: apache/kafka:4.3.1
hostname: kafka
restart: unless-stopped
ports:
- "127.0.0.1:${KAFKA_EXTERNAL_PORT:-29092}:29092"
- "127.0.0.1:${KAFKA_EXTERNAL_PORT:?Скопируйте .env.example в .env}:29092"
environment:
KAFKA_NODE_ID: "1"
KAFKA_PROCESS_ROLES: "broker,controller"
KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093"
KAFKA_LISTENERS: "INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:29092"
KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_EXTERNAL_PORT:-29092}"
KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_EXTERNAL_PORT:?Скопируйте .env.example в .env}"
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT"
KAFKA_INTER_BROKER_LISTENER_NAME: "INTERNAL"
KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
@@ -150,8 +204,29 @@ services:
retries: 20
start_period: 30s
kafka-exporter:
image: danielqsj/kafka-exporter:v1.9.0
restart: unless-stopped
depends_on:
kafka:
condition: service_healthy
command: ["--kafka.server=kafka:9092"]
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9308/metrics >/dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 15
start_period: 10s
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
# Под профилем — чтобы обычный подъём стенда её не трогал.
generator:
<<: *generator-common
profiles: ["generator"]
command: ["batch"]
postgres-metadata:
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
image: postgres:16-alpine
restart: unless-stopped
command:
- postgres
@@ -160,13 +235,13 @@ services:
- -c
- max_connections=50
environment:
POSTGRES_USER: ${POSTGRES_ADMIN_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:-postgres-local}
POSTGRES_USER: ${POSTGRES_ADMIN_USER:?Скопируйте .env.example в .env}
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
POSTGRES_DB: postgres
AIRFLOW_METADATA_USER: ${AIRFLOW_METADATA_USER:-airflow}
AIRFLOW_METADATA_PASSWORD: ${AIRFLOW_METADATA_PASSWORD:-airflow-local}
SUPERSET_METADATA_USER: ${SUPERSET_METADATA_USER:-superset}
SUPERSET_METADATA_PASSWORD: ${SUPERSET_METADATA_PASSWORD:-superset-local}
AIRFLOW_METADATA_USER: ${AIRFLOW_METADATA_USER:?Скопируйте .env.example в .env}
AIRFLOW_METADATA_PASSWORD: ${AIRFLOW_METADATA_PASSWORD:?Скопируйте .env.example в .env}
SUPERSET_METADATA_USER: ${SUPERSET_METADATA_USER:?Скопируйте .env.example в .env}
SUPERSET_METADATA_PASSWORD: ${SUPERSET_METADATA_PASSWORD:?Скопируйте .env.example в .env}
volumes:
- ./infra/postgres/init/10-metadata-databases.sh:/docker-entrypoint-initdb.d/10-metadata-databases.sh:ro
- postgres_metadata_data:/var/lib/postgresql/data
@@ -180,6 +255,8 @@ services:
airflow-init:
<<: *airflow-common
build:
context: ./infra/airflow
user: "0:0"
restart: "no"
depends_on:
@@ -198,7 +275,7 @@ services:
condition: service_completed_successfully
command: api-server
ports:
- "127.0.0.1:${AIRFLOW_PORT:-28080}:8080"
- "127.0.0.1:${AIRFLOW_PORT:?Скопируйте .env.example в .env}:8080"
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/api/v2/monitor/health | python -c 'import json,sys; sys.exit(0 if json.load(sys.stdin)[\"metadatabase\"][\"status\"] == \"healthy\" else 1)'"]
@@ -214,7 +291,29 @@ services:
airflow-init:
condition: service_completed_successfully
command: scheduler
mem_limit: 640m
# Пульт мира зовёт генератор отдельным контейнером, а при LocalExecutor
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660
# и группа `docker`, чей GID на каждой машине свой и живёт в `.env`.
group_add:
- ${DOCKER_GID:?Скопируйте .env.example в .env}
# Тома перечислены заново: список службы общий не дополняет, а заменяет.
# Появится новый том у остальных служб Airflow — добавьте и сюда.
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./dags:/opt/airflow/dags:ro
- ./sql:/opt/airflow/sql:ro
- ./generator:/opt/airflow/world/generator:ro
- ./data:/opt/airflow/world/data:ro
- ./.dockerignore:/opt/airflow/world/.dockerignore:ro
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
- airflow_logs:/opt/airflow/logs
- airflow_auth:/opt/airflow/auth
# LocalExecutor запускает здесь до четырех процессов задач одновременно.
# 640 МиБ дали OOM, а 1 ГиБ - давление на memory.max при штатном dq_check;
# 1,5 ГиБ оставляет запас при AIRFLOW__CORE__PARALLELISM=4.
mem_limit: 1536m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"]
interval: 10s
@@ -241,6 +340,8 @@ services:
superset-init:
<<: *superset-common
build:
context: ./infra/superset
restart: "no"
healthcheck:
disable: true
@@ -259,7 +360,7 @@ services:
superset-init:
condition: service_completed_successfully
ports:
- "127.0.0.1:${SUPERSET_PORT:-28088}:8088"
- "127.0.0.1:${SUPERSET_PORT:?Скопируйте .env.example в .env}:8088"
mem_limit: 1g
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8088/health >/dev/null"]
@@ -269,7 +370,7 @@ services:
start_period: 30s
prometheus:
image: ${PROMETHEUS_IMAGE:-prom/prometheus:v3.13.2}
image: prom/prometheus:v3.13.2
restart: unless-stopped
depends_on:
clickhouse-keeper:
@@ -287,7 +388,7 @@ services:
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
ports:
- "127.0.0.1:${PROMETHEUS_PORT:-29090}:9090"
- "127.0.0.1:${PROMETHEUS_PORT:?Скопируйте .env.example в .env}:9090"
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9090/-/ready >/dev/null 2>&1"]
@@ -297,23 +398,27 @@ services:
start_period: 10s
grafana:
image: ${GRAFANA_IMAGE:-grafana/grafana:13.1.1}
image: clickstream-grafana:local
build:
context: ./infra/grafana
restart: unless-stopped
depends_on:
prometheus:
condition: service_healthy
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:?Скопируйте .env.example в .env}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_NEWS_NEWS_FEED_ENABLED: "false"
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env}
volumes:
- ./infra/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ./infra/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- grafana_data:/var/lib/grafana
ports:
- "127.0.0.1:${GRAFANA_PORT:-23000}:3000"
- "127.0.0.1:${GRAFANA_PORT:?Скопируйте .env.example в .env}:3000"
mem_limit: 512m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:3000/api/health >/dev/null"]
+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()
+67
View File
@@ -0,0 +1,67 @@
"""Приём заказов: пакетный забор слепка из Kafka в хранилище.
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор
порции в сырьё, затем разбор того же среза в типизированный ODS и в таблицу
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, —
работник пульта мира после проигрыша дня, — и ждёт конца.
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов
приезжает раз в модельный день целой выгрузкой, у которой есть начало и конец,
и забирает её запрос по команде. Push против pull — два режима на одном стенде,
каждый там, где ему место по природе источника.
Решения и доводы целиком — ADR 0008 (забор) и ADR 0010 (версии в ODS); форма
перехода — docs/architecture/orders/ingestion.md.
"""
from __future__ import annotations
import datetime
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.sdk import dag
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
# Запросы лежат файлами в sql/ репозитория, разложенные по слоям хранилища;
# сюда их монтирует compose — всем службам Airflow сразу.
SQL_ROOT = "/opt/airflow/sql"
@dag(
dag_id="orders_ingest",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
# Чтец у топика один, и группа потребителей у него одна. Два прогона разом
# дрались бы за неё, а слепок разъехался бы по двум _load_id; второй
# прогон подождёт своей очереди.
max_active_runs=1,
template_searchpath=SQL_ROOT,
tags=["заказы"],
)
def orders_ingest():
"""Забрать приехавший слепок заказов и разложить его по слоям."""
pull_batch = SQLExecuteQueryOperator(
task_id="pull_batch",
conn_id="clickhouse_default",
sql="stg/orders_raw_load.sql",
parameters={"load_id": "{{ run_id }}"},
do_xcom_push=False,
)
parse_batch = SQLExecuteQueryOperator(
task_id="parse_batch",
conn_id="clickhouse_default",
sql="ods/order_parse.sql",
parameters={"load_id": "{{ run_id }}"},
split_statements=True,
do_xcom_push=False,
)
# ClickHouse не даёт транзакции между двумя вставками parse_batch. Один
# task_id сохраняет их порядок и одну точку повтора для неизменного load_id.
pull_batch >> parse_batch
orders_ingest()
+84 -106
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import datetime
import uuid
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.sdk import Connection, dag, get_current_context, task
CLUSTER = "clickstream_cluster"
@@ -29,98 +30,73 @@ EXPECTED_TABLES = [
(LOCAL_TABLE, "ReplicatedMergeTree"),
]
# Ноду 2 пробник читает не своим подключением, а запросом remote() с ноды 1: у
# Airflow подготовлено одно подключение — к clickhouse-01, и второго ради
# пробника не заводят. При этом remote('clickhouse-02:9000', ...) делает
# инициатором распределённого запроса саму ноду 2 — проверяется именно это, а
# не доступность ноды 2 по сети. Порт 9000 — межсерверный, тогда как
# подключение Airflow ходит по HTTP на 8123.
# У Airflow одно подключение — к ноде 1; второго ради пробника не заводят.
# Ноду 2 он читает через remote(), который не использует секрет из описания
# кластера, поэтому учётные данные etl передаются явно. Порт 9000 — нативный,
# тогда как подключение Airflow ходит по HTTP на 8123. Trace-журнал сервера
# видит пароль: это допустимо только для локального учебного стенда.
NODES = (
("ноде 1", "system.tables"),
("ноде 2", "remote('clickhouse-02:9000', system.tables)"),
(
"ноде 2",
"""remote(
'clickhouse-02:9000', 'system', 'tables',
{remote_user:String}, {remote_password:String}
)""",
),
)
def _clickhouse_client():
# clickhouse_connect стоит только в образе Airflow, а малые проверки грузят
# этот модуль обычным интерпретатором, где пакета нет. Импорт верхнего
# уровня красит make config-test, поэтому он живёт здесь.
import clickhouse_connect
def _remote_parameters() -> dict[str, str]:
connection = Connection.get("clickhouse_default")
return clickhouse_connect.get_client(
host=connection.host,
port=connection.port,
username=connection.login or "default",
password=connection.password or "",
database=connection.schema or "default",
connect_timeout=5,
send_receive_timeout=30,
)
return {
"remote_user": connection.login,
"remote_password": connection.password,
}
def _table_engines(client, source: str) -> list[tuple[str, str]]:
result = client.query(
def _clickhouse_hook() -> ClickHouseHook:
return ClickHouseHook(clickhouse_conn_id="clickhouse_default")
def _table_engines(hook: ClickHouseHook, source: str) -> list[tuple[str, str]]:
return hook.get_records(
f"""
SELECT name, engine
FROM {source}
WHERE database = 'default'
AND name IN ('{LOCAL_TABLE}', '{DISTRIBUTED_TABLE}')
ORDER BY name
"""
""",
parameters=_remote_parameters(),
)
return result.result_rows
def _drop_tables(client) -> None:
client.command(
def _drop_tables(hook: ClickHouseHook) -> None:
hook.run(
[
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} "
f"ON CLUSTER {CLUSTER} SYNC"
)
client.command(
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC"
f"ON CLUSTER {CLUSTER} SYNC",
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC",
],
)
def _assert_tables_absent(client) -> None:
# Отсутствие таблиц проверяют обе задачи — перед созданием и после уборки,
# — поэтому у этой проверки своё имя, а остальные живут прямо в теле задач.
def _assert_tables_absent(hook: ClickHouseHook) -> None:
for node_name, source in NODES:
remaining = _table_engines(client, source)
remaining = _table_engines(hook, source)
if remaining:
raise RuntimeError(
f"служебные таблицы остались на {node_name}: {remaining}"
)
def _assert_tables_created(client) -> None:
for node_name, source in NODES:
actual_tables = _table_engines(client, source)
if actual_tables != EXPECTED_TABLES:
raise RuntimeError(
f"неверный набор таблиц на {node_name}: {actual_tables}"
)
def _assert_marker_path(
*,
distributed_rows: list[tuple[int, str, str]],
write_hostname: str,
node_2_hostname: str,
marker: str,
) -> None:
if write_hostname == node_2_hostname:
raise RuntimeError("запись и чтение маркера должны выполняться с разных нод")
expected_rows = [(1, write_hostname, marker)]
if distributed_rows != expected_rows:
raise RuntimeError(
"нода 2 не прочитала маркер первого шарда через Distributed: "
f"{marker}, получено {distributed_rows}"
)
@dag(
dag_id="test_clickhouse",
schedule=None,
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
catchup=False,
tags=["проверка"],
doc_md=__doc__,
@@ -128,11 +104,11 @@ def _assert_marker_path(
def test_clickhouse():
@task
def prepare_tables() -> None:
client = _clickhouse_client()
try:
_drop_tables(client)
_assert_tables_absent(client)
client.command(
hook = _clickhouse_hook()
_drop_tables(hook)
_assert_tables_absent(hook)
hook.run(
[
f"""
CREATE TABLE default.{LOCAL_TABLE} ON CLUSTER {CLUSTER}
(
@@ -143,9 +119,7 @@ def test_clickhouse():
'{{replica}}'
)
ORDER BY marker
"""
)
client.command(
""",
f"""
CREATE TABLE default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER}
AS default.{LOCAL_TABLE}
@@ -155,70 +129,77 @@ def test_clickhouse():
'{LOCAL_TABLE}',
cityHash64(marker)
)
"""
""",
],
)
for node_name, source in NODES:
actual_tables = _table_engines(hook, source)
if actual_tables != EXPECTED_TABLES:
raise RuntimeError(
f"неверный набор таблиц на {node_name}: {actual_tables}"
)
_assert_tables_created(client)
finally:
client.close()
@task
def write_marker() -> dict[str, str]:
client = _clickhouse_client()
try:
hook = _clickhouse_hook()
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
client.insert(
hook.bulk_insert_rows(
f"default.{LOCAL_TABLE}",
[[marker]],
column_names=["marker"],
)
local_rows = client.query(
local_rows = hook.get_records(
f"""
SELECT hostName(), marker
FROM default.{LOCAL_TABLE}
WHERE marker = {{marker:String}}
""",
parameters={"marker": marker},
).result_rows
)
if len(local_rows) != 1 or local_rows[0][1] != marker:
raise RuntimeError(f"маркер не найден в локальной таблице: {marker}")
return {"marker": marker, "hostname": local_rows[0][0]}
finally:
client.close()
@task
def read_from_node_2(written: dict[str, str]) -> None:
client = _clickhouse_client()
try:
node_2_rows = client.query(
hook = _clickhouse_hook()
node_2_rows = hook.get_records(
"""
SELECT hostName()
FROM remote('clickhouse-02:9000', system.one)
"""
).result_rows
if len(node_2_rows) != 1:
raise RuntimeError(
f"не удалось определить имя ноды 2: {node_2_rows}"
FROM remote(
'clickhouse-02:9000', 'system', 'one',
{remote_user:String}, {remote_password:String}
)
distributed_rows = client.query(
""",
parameters=_remote_parameters(),
)
if len(node_2_rows) != 1:
raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}")
distributed_rows = hook.get_records(
f"""
SELECT _shard_num, hostName(), marker
FROM remote(
'clickhouse-02:9000',
'default',
'{DISTRIBUTED_TABLE}'
'{DISTRIBUTED_TABLE}',
{{remote_user:String}},
{{remote_password:String}}
)
WHERE marker = {{marker:String}}
""",
parameters={"marker": written["marker"]},
).result_rows
_assert_marker_path(
distributed_rows=distributed_rows,
write_hostname=written["hostname"],
node_2_hostname=node_2_rows[0][0],
marker=written["marker"],
parameters={
"marker": written["marker"],
**_remote_parameters(),
},
)
if written["hostname"] == node_2_rows[0][0]:
raise RuntimeError("запись и чтение маркера должны идти с разных нод")
expected_rows = [(1, written["hostname"], written["marker"])]
if distributed_rows != expected_rows:
raise RuntimeError(
"нода 2 не прочитала маркер первого шарда через Distributed: "
f"{written['marker']}, получено {distributed_rows}"
)
finally:
client.close()
# Уборка идёт только после успеха: упавший пробник оставляет кластер таким,
# каким сломался, а остатки сносит начало следующего запуска. Правило
@@ -227,12 +208,9 @@ def test_clickhouse():
# с упавшей проверкой (ADR 0003).
@task
def cleanup_tables() -> None:
client = _clickhouse_client()
try:
_drop_tables(client)
_assert_tables_absent(client)
finally:
client.close()
hook = _clickhouse_hook()
_drop_tables(hook)
_assert_tables_absent(hook)
prepared = prepare_tables()
written = write_marker()
+31 -52
View File
@@ -74,52 +74,10 @@ class RecordAddress(NamedTuple):
offset: int
def _assert_all_delivered(undelivered: int, marker: str) -> None:
if undelivered:
raise RuntimeError(
f"Kafka не приняла маркер за {FLUSH_TIMEOUT_SEC} с, "
f"не доставлено сообщений {undelivered}: {marker}"
)
def _assert_no_delivery_errors(delivery_errors: list[str], marker: str) -> None:
if delivery_errors:
raise RuntimeError(
f"Kafka отказалась принять маркер {marker}: {delivery_errors}"
)
def _assert_confirmed_once(addresses: list[RecordAddress], marker: str) -> None:
if len(addresses) != 1:
raise RuntimeError(
f"Kafka подтвердила запись маркера {marker} "
f"не одним сообщением: {addresses}"
)
def _assert_message_arrived(message, marker: str) -> None:
if message is None:
raise RuntimeError(
f"Kafka молчала {READ_DEADLINE_SEC} с и не вернула маркер: {marker}"
)
def _assert_no_read_error(message) -> None:
if message.error():
raise RuntimeError(f"Kafka вернула ошибку чтения: {message.error()}")
def _assert_marker_matches(message, marker: str) -> None:
if message.value() != marker.encode():
raise RuntimeError(
f"по адресу записи лежит не маркер запуска {marker}: {message.value()!r}"
)
@dag(
dag_id="test_kafka",
schedule=None,
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
catchup=False,
tags=["проверка"],
doc_md=__doc__,
@@ -127,6 +85,10 @@ def _assert_marker_matches(message, marker: str) -> None:
def test_kafka():
@task
def write_marker() -> dict[str, str | int]:
# confluent_kafka импортируется внутри задачи, а не наверху файла:
# обработчик DAG разбирает этот файл снова и снова, и импорт наверху
# оплачивался бы каждым разбором. Тяжёлые импорты Airflow советует
# держать внутри задач.
from confluent_kafka import Producer
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
@@ -138,9 +100,7 @@ def test_kafka():
if error is not None:
delivery_errors.append(str(error))
else:
addresses.append(
RecordAddress(message.partition(), message.offset())
)
addresses.append(RecordAddress(message.partition(), message.offset()))
producer = Producer(PRODUCER_CONFIG)
# produce() не пишет, а ставит сообщение в очередь: о судьбе записи
@@ -158,9 +118,20 @@ def test_kafka():
finally:
undelivered = producer.flush(FLUSH_TIMEOUT_SEC)
_assert_all_delivered(undelivered, marker)
_assert_no_delivery_errors(delivery_errors, marker)
_assert_confirmed_once(addresses, marker)
if undelivered:
raise RuntimeError(
f"Kafka не приняла маркер за {FLUSH_TIMEOUT_SEC} с, "
f"не доставлено сообщений {undelivered}: {marker}"
)
if delivery_errors:
raise RuntimeError(
f"Kafka отказалась принять маркер {marker}: {delivery_errors}"
)
if len(addresses) != 1:
raise RuntimeError(
f"Kafka подтвердила запись маркера {marker} "
f"не одним сообщением: {addresses}"
)
return {
"marker": marker,
"partition": addresses[0].partition,
@@ -190,9 +161,17 @@ def test_kafka():
# другого нечем, поэтому у чтения обязан быть крайний срок.
while message is None and time.monotonic() < deadline:
message = consumer.poll(POLL_TIMEOUT_SEC)
_assert_message_arrived(message, marker)
_assert_no_read_error(message)
_assert_marker_matches(message, marker)
if message is None:
raise RuntimeError(
f"Kafka молчала {READ_DEADLINE_SEC} с и не вернула маркер: {marker}"
)
if message.error():
raise RuntimeError(f"Kafka вернула ошибку чтения: {message.error()}")
if message.value() != marker.encode():
raise RuntimeError(
f"по адресу записи лежит не маркер запуска {marker}: "
f"{message.value()!r}"
)
finally:
consumer.close()
+277
View File
@@ -0,0 +1,277 @@
"""Пульт мира: даги, которыми двигают ось модельного времени.
Их три, и они двух родов. **Работники** играют день и запускаются руками:
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
один день в темпе модельного времени. **Выключатель** `world_live` своей работы
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
границе модельных суток.
Сыграть день — половина работы. Вторая половина: отправить слепок заказов и
дождаться, пока хранилище его заберёт. Поэтому у обоих работников за проигрышем
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
`orders_ingest`.
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
только разгоняет источники: слои после нее менти догоняет ручным запуском.
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник
спрашивает у переменной, а не у логической даты прогона.
Решения и доводы целиком — ADR 0009.
"""
from __future__ import annotations
import datetime
import logging
import os
from airflow.exceptions import AirflowException
from airflow.providers.docker.operators.docker import DockerOperator
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.sdk import Param, Variable, dag, get_current_context, task
# Факты стенда — образ генератора, сеть, адрес брокера, топик и размер
# стартового мира — приходят окружением, и называет их compose: тот же, что
# называет их разовой службе генератора.
GENERATOR_IMAGE = os.environ["GENERATOR_IMAGE"]
STAND_NETWORK = os.environ["STAND_NETWORK"]
GENERATOR_ENVIRONMENT = {
"KAFKA_BOOTSTRAP_SERVERS": os.environ["KAFKA_BOOTSTRAP_SERVERS"],
"KAFKA_TOPIC": os.environ["KAFKA_TOPIC"],
}
# Топик слепка называется аргументом, а не окружением: KAFKA_TOPIC выше — топик
# событий, и промолчи мы, заказы уехали бы к ним.
ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
# Позиция на оси: номер первого несыгранного дня. До конца инициализации
# работники не имеют права угадывать её — состояние объяснено в ADR 0013.
#
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
# при таком правиле — сыгранный дважды день, а повтор схлопнет
# ReplacingMergeTree. Увеличение молча съело бы день, и в мире осталась бы
# дыра, которой никто не заметит.
WORLD_POSITION = "world_position"
# Тик выключателя. Каденцию задаёт не он, а сама длина живого дня — около
# двадцати четырёх минут: тик только спрашивает «не пора ли снова».
LIVE_TICK = datetime.timedelta(minutes=25)
# Какой день играть, работник узнаёт у первой задачи. Спрашивают её все
# генераторные шаги: слепок снимается с той же позиции, что и проигрыш.
PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
TAGS = ["пульт мира"]
@task
def first_unplayed_day() -> 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 < STARTING_DAYS:
action = (
"выполните make up" if position == 0 else "выполните make rebuild-storage"
)
raise AirflowException(f"мир не готов: world_position={position}; {action}")
return position
def _generator(task_id: str, command: list[str]) -> DockerOperator:
"""Задача, зовущая генератор в его каноническом контейнере.
Внутрь образа Airflow генератор не поставить: он требует Python 3.14, а
образ несёт 3.13. Да и обещание побайтовой воспроизводимости дано для
зафиксированного образа генератора — держится оно только там.
"""
return DockerOperator(
task_id=task_id,
image=GENERATOR_IMAGE,
command=command,
network_mode=STAND_NETWORK,
environment=GENERATOR_ENVIRONMENT,
# Контейнер убирается в любом исходе: вывод генератора оператор уже
# перелил в журнал задачи, а мёртвые контейнеры копить незачем.
auto_remove="force",
# По умолчанию оператор монтирует контейнеру временный каталог. Здесь
# это ловушка: путь он заводит внутри Airflow, а монтирует демон с
# хоста, где такого пути нет. Генератору временный каталог не нужен.
mount_tmp_dir=False,
)
def _ingest_orders() -> TriggerDagRunOperator:
"""Задача забора: дёрнуть даг приёма и дождаться, чем он кончился.
Ожидание здесь несущее. Без него работник позеленел бы, не узнав, доехал
ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа
не должен переживать отказ выше (ADR 0003).
"""
return TriggerDagRunOperator(
task_id="trigger_orders_ingest",
trigger_dag_id="orders_ingest",
wait_for_completion=True,
# Умолчание — минута опроса, а весь прогон работника пачкой короче.
poke_interval=10,
)
@dag(
dag_id="world_next_day",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
tags=TAGS,
params={
"days": Param(
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 "
"и на выбор дня не влияют."
),
)
},
)
def world_next_day():
"""Прожить следующие дни пачкой, без пауз.
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
нажимом, а не десятью: сколько дней играть — параметр запуска. Первый
день работник берет из `world_position`. Логическая дата прогона на выбор
дня не влияет.
"""
@task
def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранным дням и только по успеху."""
days = get_current_context()["params"]["days"]
position = first_day + days
Variable.set(WORLD_POSITION, str(position))
logging.info(
"дни %s–%s сыграны; новая позиция — %s", first_day, position - 1, position
)
first_day = first_unplayed_day()
played = _generator(
"play_days",
["batch", "--day", PLAYED_DAY, "--days", "{{ params.days }}"],
)
# Разгон на N дней отправляет N слепков — по одному за сыгранный день, и
# каждый со своим сдвигом: прогон дня D везёт слепок дня D−1. Забор при
# этом остаётся один.
sent = _generator(
"send_snapshots",
[
"snapshot",
"--day",
PLAYED_DAY,
"--days",
"{{ params.days }}",
"--topic",
ORDERS_TOPIC,
],
)
first_day >> played >> sent >> _ingest_orders() >> remember_played(first_day)
@dag(
dag_id="world_live_day",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
tags=TAGS,
)
def world_live_day():
"""Прожить следующий день в темпе модельного времени.
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
реальные минуты, и суточная волна разворачивается на глазах. Запускается
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
Следующий день работник берет из `world_position`. Логическая дата прогона
на выбор дня не влияет.
"""
@task
def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранному дню и только по успеху."""
Variable.set(WORLD_POSITION, str(first_day + 1))
logging.info("сыгран день %s; новая позиция — %s", first_day, first_day + 1)
first_day = first_unplayed_day()
played = _generator("play_day", ["live", "--day", PLAYED_DAY])
# Слепок уезжает пачкой и после дня, а не в темпе: у выгрузки бэкенда темпа
# нет вовсе — она снимается на границе суток целиком.
sent = _generator(
"send_snapshot",
["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC],
)
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_id="world_live",
schedule=LIVE_TICK,
start_date=START_DATE,
catchup=False,
is_paused_upon_creation=True,
max_active_runs=1,
tags=TAGS,
)
def world_live():
"""Выключатель: пока включён, мир живёт день за днём.
Своей работы у выключателя нет — он дёргает `world_live_day` и ждёт конца.
Ожидание тут несущая конструкция, а не вежливость: без него тик шёл бы
независимо от хода дня, лишние прогоны скопились бы очередью, и мир потом
промчался бы по ней без всякого темпа.
Ждём триггером, а не сенсором: оператор опрашивает тот прогон, который сам
и создал, и ссылка на дочерний прогон видна прямо отсюда. Упал день —
краснеет и выключатель.
"""
TriggerDagRunOperator(
task_id="trigger_live_day",
trigger_dag_id="world_live_day",
wait_for_completion=True,
)
world_next_day()
world_live_day()
world_live()
+377
View File
@@ -0,0 +1,377 @@
"""Создание и пересоздание прикладного мира.
Compose поднимает службы, а этот граф создаёт всё прикладное поверх них:
топики, схему ClickHouse, данные стартовых дней, позицию модельного времени и
готовые слои. `world_initialize` безопасен для обычного `make up`;
`world_recreate` — явная операция обслуживания, которая сначала объявляет
прежний мир недействительным.
Контракт состояний и доводы — ADR 0013 и спека жизненного цикла мира.
"""
from __future__ import annotations
import datetime
import json
import logging
import os
import time
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.providers.docker.hooks.docker import DockerHook
from airflow.providers.docker.operators.docker import DockerOperator
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.sdk import Variable, chain, dag, task
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
TAGS = ["жизненный цикл мира"]
WORLD_POSITION = "world_position"
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
HITS_TOPIC = os.environ["KAFKA_TOPIC"]
ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
KAFKA_BOOTSTRAP_SERVERS = os.environ["KAFKA_BOOTSTRAP_SERVERS"]
GENERATOR_IMAGE = os.environ["GENERATOR_IMAGE"]
STAND_NETWORK = os.environ["STAND_NETWORK"]
SQL_ROOT = Path("/opt/airflow/sql")
DDL_ROOT = SQL_ROOT / "ddl"
WORLD_ROOT = Path("/opt/airflow/world")
INVENTORY = WORLD_ROOT / "data/world-inventory.json"
CLICKHOUSE_CONNECTION = "clickhouse_lifecycle"
CLICKHOUSE_CLUSTER = "clickstream_cluster"
APPLICATION_DATABASES = ("stg", "dm", "dds", "ods", "dic")
CONSUMER_GROUPS = ("clickstream_hits", "clickstream_orders")
TOPICS = {HITS_TOPIC: 2, ORDERS_TOPIC: 1}
def _clickhouse_hook() -> ClickHouseHook:
"""Подключить владельца жизненного цикла."""
return ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
def _generator(task_id: str, command: list[str]) -> DockerOperator:
"""Запустить канонический образ генератора в сети стенда."""
return DockerOperator(
task_id=task_id,
image=GENERATOR_IMAGE,
command=command,
network_mode=STAND_NETWORK,
environment={
"KAFKA_BOOTSTRAP_SERVERS": KAFKA_BOOTSTRAP_SERVERS,
"KAFKA_TOPIC": HITS_TOPIC,
},
# Вывод уже переехал в журнал задачи; завершённый контейнер не нужен.
auto_remove="force",
# Временный путь Airflow существует внутри контейнера, но монтировать
# его пытается хостовый Docker. Генератору этот каталог не нужен.
mount_tmp_dir=False,
)
def _kafka_error_code(error: Exception) -> int:
"""Код ошибки из KafkaException."""
return error.args[0].code()
@dag(
dag_id="world_initialize",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
template_searchpath=str(SQL_ROOT),
tags=TAGS,
)
def world_initialize():
"""Создать стартовый мир, если готового мира ещё нет."""
@task.branch
def choose_path() -> str:
raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None:
logging.info("world_position отсутствует: создаём первый мир")
return "create_topics"
try:
position = int(raw_position)
except (TypeError, ValueError) as error:
raise AirflowException(
f"world_position={raw_position!r} не является номером дня; "
"выполните make rebuild-storage"
) from error
if position == 0:
logging.info("очистка завершена: создаём мир заново")
return "create_topics"
if position == -1:
raise AirflowException(
"world_position=-1: очистка мира не завершена; "
"повторите make rebuild-storage"
)
if position >= STARTING_DAYS:
logging.info("мир уже существует: world_position=%s", position)
return "world_already_exists"
raise AirflowException(
f"world_position={position}: недопустимое незавершённое состояние; "
"выполните make rebuild-storage"
)
@task
def world_already_exists() -> None:
"""Читаемый зелёный конец ветки без изменений."""
logging.info("инициализация не требуется: готовый мир не изменён")
@task
def create_topics() -> None:
"""Создать топики до появления читающих их таблиц ClickHouse."""
from confluent_kafka import KafkaError, KafkaException
from confluent_kafka.admin import AdminClient, NewTopic
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
futures = admin.create_topics(
[
NewTopic(name, num_partitions=partitions, replication_factor=1)
for name, partitions in TOPICS.items()
],
request_timeout=30,
operation_timeout=15,
)
for name, future in futures.items():
try:
future.result()
logging.info("топик %s создан", name)
except KafkaException as error:
if _kafka_error_code(error) != KafkaError.TOPIC_ALREADY_EXISTS:
raise
logging.info("топик %s уже существует", name)
metadata = admin.list_topics(timeout=15)
for name, expected_partitions in TOPICS.items():
topic = metadata.topics.get(name)
actual_partitions = (
len(topic.partitions) if topic and not topic.error else 0
)
if actual_partitions != expected_partitions:
raise AirflowException(
f"у топика {name} разделов {actual_partitions}, "
f"ожидалось {expected_partitions}; "
"выполните make rebuild-storage"
)
@task
def build_generator_image() -> None:
"""Собрать генератор из текущего дерева, а не из старого образа."""
client = DockerHook(
docker_conn_id=None,
base_url="unix://var/run/docker.sock",
timeout=900,
).get_conn()
output = client.build(
path=str(WORLD_ROOT),
dockerfile="generator/Dockerfile",
tag=GENERATOR_IMAGE,
rm=True,
decode=True,
)
for item in output:
if error := item.get("error"):
raise AirflowException(
f"сборка генератора завершилась ошибкой: {error}"
)
if stream := item.get("stream", "").strip():
logging.info("docker build: %s", stream)
@task
def wait_for_events() -> None:
"""Дождаться приёма всех событий описи, включая строки брака."""
inventory = json.loads(INVENTORY.read_text(encoding="utf-8"))
expected = sum(day["events"] for day in inventory["days"])
first_date = inventory["days"][0]["date"]
last_date = inventory["days"][-1]["date"]
query = f"""
SELECT
(SELECT count() FROM ods.event_dist
WHERE EventDate BETWEEN '{first_date}' AND '{last_date}')
+ (SELECT count() FROM ods.event_errors_dist)
"""
hook = _clickhouse_hook()
arrived = 0
for attempt in range(1, 101):
row = hook.get_first(query)
if row is None:
raise AirflowException("ClickHouse не вернул счётчик приёма")
arrived = int(row[0])
if arrived >= expected:
logging.info("стартовый мир принят: строк %s", arrived)
return
if attempt % 5 == 0:
logging.info("принято %s из %s строк", arrived, expected)
time.sleep(3)
raise AirflowException(
f"стартовый мир не принят за 300 с: строк {arrived} из {expected}; "
"проверьте журнал send_initial_events и "
"SELECT * FROM system.kafka_consumers"
)
@task
def remember_starting_position() -> None:
"""Объявить мир готовым только после приёма обоих источников."""
Variable.set(WORLD_POSITION, str(STARTING_DAYS))
logging.info("мир готов: world_position=%s", STARTING_DAYS)
route = choose_path()
ready = world_already_exists()
topics = create_topics()
ddl_files = sorted(DDL_ROOT.glob("*.sql"))
if not ddl_files:
raise AirflowException(f"в {DDL_ROOT} нет файлов DDL")
ddl_tasks = [
SQLExecuteQueryOperator(
task_id=f"apply_{path.stem.replace('-', '_')}",
conn_id=CLICKHOUSE_CONNECTION,
sql=f"ddl/{path.name}",
split_statements=True,
do_xcom_push=False,
)
for path in ddl_files
]
image = build_generator_image()
events = _generator(
"send_initial_events",
["batch", "--day", "0", "--days", str(STARTING_DAYS)],
)
arrived = wait_for_events()
snapshots = _generator(
"send_initial_snapshots",
[
"snapshot",
"--day",
"0",
"--days",
str(STARTING_DAYS),
"--topic",
ORDERS_TOPIC,
],
)
orders = TriggerDagRunOperator(
task_id="trigger_orders_ingest",
trigger_dag_id="orders_ingest",
wait_for_completion=True,
poke_interval=10,
)
position = remember_starting_position()
etl = TriggerDagRunOperator(
task_id="trigger_etl_pipeline",
trigger_dag_id="etl_pipeline",
wait_for_completion=True,
poke_interval=10,
)
route >> [ready, topics]
chain(topics, *ddl_tasks, image, events, arrived, snapshots, orders, position, etl)
@dag(
dag_id="world_recreate",
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
tags=TAGS,
)
def world_recreate():
"""Удалить только прикладной мир и делегировать его создание."""
@task
def invalidate_world() -> None:
Variable.set(WORLD_POSITION, "-1")
logging.info("прежний мир недействителен: world_position=-1")
@task
def drop_application_databases() -> None:
hook = _clickhouse_hook()
for database in APPLICATION_DATABASES:
logging.info("удаляем базу %s", database)
hook.run(
f"DROP DATABASE IF EXISTS {database} "
f"ON CLUSTER {CLICKHOUSE_CLUSTER} SYNC"
)
@task
def delete_consumer_groups() -> None:
from confluent_kafka import KafkaError, KafkaException
from confluent_kafka.admin import AdminClient
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
remaining = set(CONSUMER_GROUPS)
for attempt in range(1, 31):
futures = admin.delete_consumer_groups(
sorted(remaining), request_timeout=15
)
for group, future in futures.items():
try:
future.result()
remaining.discard(group)
logging.info("группа %s удалена", group)
except KafkaException as error:
code = _kafka_error_code(error)
if code == KafkaError.GROUP_ID_NOT_FOUND:
remaining.discard(group)
elif code != KafkaError.NON_EMPTY_GROUP:
raise
if not remaining:
return
if attempt % 5 == 0:
logging.info("ждём выхода потребителей из групп: %s", sorted(remaining))
time.sleep(2)
raise AirflowException(
f"за 60 с не освободились группы потребителей: {sorted(remaining)}"
)
@task
def delete_topics() -> None:
from confluent_kafka import KafkaError, KafkaException
from confluent_kafka.admin import AdminClient
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
futures = admin.delete_topics(list(TOPICS), request_timeout=30)
for name, future in futures.items():
try:
future.result()
logging.info("топик %s удалён", name)
except KafkaException as error:
if _kafka_error_code(error) != KafkaError.UNKNOWN_TOPIC_OR_PART:
raise
for _ in range(30):
existing = set(admin.list_topics(timeout=10).topics) & set(TOPICS)
if not existing:
return
time.sleep(1)
raise AirflowException(f"топики не исчезли за 30 с: {sorted(existing)}")
@task
def mark_world_clean() -> None:
Variable.set(WORLD_POSITION, "0")
logging.info("очистка завершена: world_position=0")
invalid = invalidate_world()
databases = drop_application_databases()
groups = delete_consumer_groups()
topics = delete_topics()
clean = mark_world_clean()
initialize = TriggerDagRunOperator(
task_id="trigger_world_initialize",
trigger_dag_id="world_initialize",
wait_for_completion=True,
poke_interval=10,
)
invalid >> databases >> groups >> topics >> clean >> initialize
world_initialize()
world_recreate()
+99
View File
@@ -0,0 +1,99 @@
{
"seed": 20260601,
"generator_version": "0.1.0",
"catalog_sha256": "4fffceb3ec278c0b364ec84d936d991946ddd2a0912ed9c36471ff7a55c7483f",
"days": [
{
"day": 0,
"date": "2026-06-01",
"events": 50628,
"sha256": "d09704ed99e45824e451e2f6bbbbd5c547df382f7a582699955010f3807eb0ee",
"orders": {
"match": 192,
"cancelled": 20,
"lost_event": 3,
"duplicate_event": 2,
"amount_delta": 6
}
},
{
"day": 1,
"date": "2026-06-02",
"events": 49727,
"sha256": "8005d2934eb353478e35331d7ae7ae6876c7934b2d9032fde92cf6ce86d6460f"
},
{
"day": 2,
"date": "2026-06-03",
"events": 52363,
"sha256": "e13f6f3f3977386a94d0e63b6849675cb5d2486a277d559fd415923cfc82e53a"
},
{
"day": 3,
"date": "2026-06-04",
"events": 53159,
"sha256": "91c713add62222adc3f89c04e6cc087d8c44e2385d14d0464e2c016b6952b9fd"
},
{
"day": 4,
"date": "2026-06-05",
"events": 50087,
"sha256": "b7fc27d73fc157a48bece0189c7983f6bc672f052e0703cd799b9774da4b904e"
},
{
"day": 5,
"date": "2026-06-06",
"events": 47169,
"sha256": "82cb8017d53d6dee8438d3683b7a901f1ace139f3b8ae836215722bb580fe207"
},
{
"day": 6,
"date": "2026-06-07",
"events": 48686,
"sha256": "b50ad0fcc7d1ea0c637cfcba98e5c2b55d65215cffa3693bc77edeaa83e6c783"
},
{
"day": 7,
"date": "2026-06-08",
"events": 49394,
"sha256": "358d51b36221d822960237702fa6327fb663a57cf49cc4415a859b16e5aa05a2"
}
],
"snapshots": [
{
"day": 0,
"date": "2026-06-01",
"sha256": "93b39f261cbbe894402ebe0525460fed1809e5712639be4b73b79dac2b39f5bd"
},
{
"day": 1,
"date": "2026-06-02",
"sha256": "4fef382cd84868ccfb61a9d8610cdd26f9a749bbae8b20800c45e520e9ce414e"
},
{
"day": 2,
"date": "2026-06-03",
"sha256": "caf9ed2e77d2fcc99066a548dcba60f61f1ff0d4402de31457a06f23fd2d8306"
},
{
"day": 3,
"date": "2026-06-04",
"sha256": "cdb9e4e00b57c04985866b5a851a8e2f23b3e9205ea131c7a5076672ee8b5669"
},
{
"day": 4,
"date": "2026-06-05",
"sha256": "c58d2c20fe3b4cee2973f34c322090dbb66d3793bfaab0337c22b53fb5a3ec7f"
},
{
"day": 5,
"date": "2026-06-06",
"sha256": "bbf28b1d375e4815695c72a5915a22a3c9b36195b97ec4cc7c739466dbe70b53"
},
{
"day": 6,
"date": "2026-06-07",
"sha256": "8ca077576bcf014c0d492c7d7190e38c0e46ec95951ad840df93eb75ee5a00d3"
}
]
}
+39 -11
View File
@@ -10,6 +10,19 @@
обработчик DAG. Triggerer не запускается: в каркасе нет отложенных задач.
Два интеграционных пробника написаны через публичный `airflow.sdk`.
Долгоживущие процессы Airflow зависят от `airflow-init`, а Superset — от
`superset-init`. Это ребро нужно самому `up --wait`: Docker Compose 2.40.3
считает успешно завершившийся одноразовый сервис без зависимого упавшим и
возвращает единицу — поведение проверено при исполнении #37. Зависимость
включает результат подготовки в общий граф ожидания.
У каждого общего локального образа один блок `build`. Он находится у службы
инициализации. Остальные службы зависят от неё и используют тот же `image`.
`docker compose up --build` завершает сборку до запуска контейнеров, поэтому
одного блока достаточно. В Buildx 0.30.1 одинаковые блоки у нескольких служб
запускают параллельный экспорт одного тега. Иногда сборка завершается ошибкой
`image already exists`.
Для входа выбран SimpleAuthManager. У него нет команды создания пользователя,
поэтому `airflow-init` записывает пароль администратора из окружения в
JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит
@@ -24,13 +37,20 @@ JSON-файл отдельного тома `airflow_auth`. Новый имен
пользователи и строки подключения. Это экономит один контейнер, но сохраняет
границу владения данными.
Airflow получает подготовленное общее подключение к `clickhouse-01`, а
Superset — подключение `clickhousedb://` к `clickhouse-02`. Провайдер
ClickHouse для Airflow не нужен: локальный образ содержит прямой клиент.
Пробник создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через
`remote` читает `Distributed` на второй. Разные ноды сохраняют учебную
ловушку: забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow
на первой ноде прошла успешно.
Airflow получает два подготовленных подключения к `clickhouse-01`: рабочее
`clickhouse_default` и административное `clickhouse_lifecycle` для дагов
жизненного цикла мира ([ADR 0013](0013-world-lifecycle.md)). Оба имеют тип
`clickhouse`, который регистрирует официальный провайдер
`apache-airflow-providers-clickhousedb`. SQL-файлы исполняет общий
`SQLExecuteQueryOperator`, а программные запросы идут через `ClickHouseHook`.
Сам провайдер работает через `clickhouse-connect`; его версия закреплена
отдельно, чтобы сборка не меняла транспорт незаметно.
Superset получает подключение `clickhousedb://` к `clickhouse-02`. Пробник
Airflow создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через
`remote` читает `Distributed` на второй. Разные ноды сохраняют учебную ловушку:
забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow на первой
ноде прошла успешно.
Клиент Kafka также добавлен прямо в образ Airflow. Официальный провайдер
использует тот же `confluent-kafka`, а пробнику не нужны его подключение,
@@ -76,10 +96,18 @@ Prometheus читает встроенные точки метрик двух с
Оттуда взяты `airflow.sdk`, обязательный отдельный обработчик DAG, возможность
не запускать triggerer и проверка конкретных компонентов здоровья.
Для интеграционных пробников проверены `Connection.get` в публичном
`airflow.sdk`, запросы через `clickhouse-connect` и состав официального
провайдера Kafka. Выбран прямой `confluent-kafka`: провайдер строит свои
подключения и обёртки поверх него, которые двум коротким пробникам не нужны.
Для ClickHouse через Context7 и официальную документацию Airflow проверены
`ClickHouseHook`, тип подключения `clickhouse` и работа
`SQLExecuteQueryOperator` с SQL-файлом, параметрами и несколькими выражениями.
Закреплён `apache-airflow-providers-clickhousedb==1.0.0`: на 21 августа
2026 года это последний стабильный выпуск; `1.0.1` опубликован только как
предварительная версия. Зависимость провайдера допускает любой
`clickhouse-connect>=1.3.0`, поэтому прежний `clickhouse-connect==1.6.0`
оставлен прямой зависимостью образа.
Для Kafka проверен состав официального провайдера. Выбран прямой
`confluent-kafka`: провайдер строит свои подключения и обёртки поверх него,
которые двум коротким пробникам не нужны.
Драйверы и строки подключения проверены по документации Superset 6.1.0:
[подключения к базам](https://superset.apache.org/user-docs/6.1.0/databases/),
+27
View File
@@ -114,3 +114,30 @@ ClickHouse, Kafka и Airflow, 17 — в дашборде генератора, 1
(спека, раздел 10), но рассказать урок можно только про то, что дашборд
показывает. Скопируй мы состав предшественника — урок неизбежно получился бы
про пульс планировщика.
21 августа 2026 года дашборд «Данные» собран. Один пункт решения исполнен не
буквально: у панели потока нет ряда «против вчерашнего». Сдвинуть ряд на
модельные сутки значит записать ускорение мира в JSON дашборда — ровно ту
пометку, от которой этот же ADR отговаривает выше, — а реальные сутки на стенде
не с чем сравнивать. Довод целиком и остальное устройство дашборда —
[docs/architecture/monitoring/data.md](../architecture/monitoring/data.md).
Тогда же собраны «Кластер» и «Запросы». Довод про `clusterAllReplicas`
подтвердился с одной поправкой: очередь распределённых DDL лежит не у ноды, а в
keeper, и обе ноды читают её целиком — запрос вернул с каждой одни и те же 124
записи. Обёртка там не добавила бы правды, а удвоила бы каждую строку, поэтому
единственная панель «Кластера» без неё — эта. Keeper своих системных таблиц не
имеет вовсе, и панель по нему приходит из Prometheus.
«Запросы» получили на две панели больше, чем сказано выше. Разбор запросов по
посетителям продиктован [ADR 0007](0007-clickhouse-access.md): своя учётка
Grafana заведена там ровно затем, чтобы её запросы были отличимы от
человеческих.
Отдельная панель ошибок понадобилась потому, что отказ достаётся шарду: в
замере с принудительным пределом памяти каждая нода записала свой, и строка
шарда видна только без фильтра по инициатору. Здешний сюжет про тяжёлый
`GROUP BY` при этом кончается не отказом, а сбросом на диск
([ADR 0004](0004-resource-limits.md)) — отказ на стенде приходится вызывать
настройкой. Устройство обоих дашбордов —
[cluster.md](../architecture/monitoring/cluster.md) и
[queries.md](../architecture/monitoring/queries.md).
+21 -4
View File
@@ -19,16 +19,23 @@
Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел
памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и
о самой машине ничего не знает. Остальные `mem_limit` не меняются.
о самой машине ничего не знает. На 1 августа остальные `mem_limit` не менялись.
Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются
статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`,
`AIRFLOW__CORE__PARALLELISM` и девять неизменённых `mem_limit`. Основания,
`AIRFLOW__CORE__PARALLELISM` и восемь оставшихся `mem_limit`. Основания,
кроме отменённого, у них нет. Кто в них упрётся — меняет по первому
свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus
сюда не входит: неделя истории — осознанный учебный выбор, и цена у него
дисковая.
27 августа таким пределом впервые стала коробка `airflow-scheduler`:
LocalExecutor исполняет в ней до четырех процессов задач одновременно, а
640 МиБ этого не вместили. Коробка поднята до 1,5 ГиБ. Ресурсные ограничения
из отдельных дагов удалены. Общий предел параллелизма уже задает Airflow.
`max_active_runs=1` остается там, где два запуска делили бы один донор и могли
повредить публикацию.
Ресурсный довод ADR 0001 отозван.
## Почему
@@ -94,8 +101,8 @@ ClickHouse сам понижает каждый до 512 МиБ, а на сте
заранее, и в покое стенд занимает около 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 предел
считается от коробки, а не от машины, сверены по документации ClickHouse через
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 не резервирует разницу между
пиком и пределом, поэтому этот запас не увеличивает расход памяти в покое.
+246
View File
@@ -0,0 +1,246 @@
# ADR 0005. Приём событий: сырьё в STG байтами, разбор функциями в ODS
Дата: 3 августа 2026 года. Статус: принято.
## Решение
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
ложится в `stg.hits_raw_dist` строкой, как пришло, рядом с метаданными доставки.
Ни типизации, ни проверки на этом шаге нет — слой сырья ничего не интерпретирует.
У формата есть следствие для DDL: он читает вход в одно значение и рассчитан на
таблицу с единственной колонкой, поэтому у чтеца она ровно одна — `raw`, а
метаданные доставки берутся только из виртуальных колонок и добавить к чтецу
что-либо своё нельзя.
Слово «непустое» приписано позже самого решения: у обещания нашлась измеренная
граница, и она датирована ниже, в разделе «Что проверено». Решения она не
меняет — от того, рождает ли пустая запись строку, выбор формата не зависит.
Типизированный слой наполняют две матвью, привязанные к `stg.hits_raw_dist`.
Поля достаются `JSONExtract`. В таблицу ошибок уходят три класса брака:
сообщение, не являющееся объектом JSON; объект, чей набор ключей разошёлся с
контрактным; объект, у которого не разобрался ключевой идентификатор или метка
времени. Остальное — в событие. Первый класс проверяется именно на объект, а не
на валидность: `isValidJSON('123')` возвращает единицу, скаляр — тоже законный
JSON.
Классы пересекаются: скаляр проваливает заодно и сверку ключей, потому что
`JSONExtractKeys` от него даёт пустой массив. Поэтому они проверяются по порядку,
а в колонку `error_class` пишется первый совпавший — `not_an_object`,
`keyset_mismatch`, `key_field_unparsed`. Приём тот же, что у `mismatch_class` в
витрине сверки: пересекающиеся классы плюс объявленный приоритет.
Присутствие полей целиком держит сверка набора ключей — одно сравнение
`arraySort(JSONExtractKeys(raw))` с контрактным списком, завёрнутым в тот же
`arraySort`. Обёртка с обеих сторон стоит ноль и снимает ошибку, которая иначе
увела бы в брак вообще всё: сорок семь CamelCase-имён, выписанных руками ровно в
байтовом порядке. Обязательны все сорок
семь полей: генератор шлёт их все в каждом событии, а «пусто» по контракту —
пустое значение, а не отсутствие ключа. Этим же закрыт критерий #43 про опечатку
в имени.
Тип проверяется не у всех колонок, а у пяти: `WatchID`, `VisitID`, `ClientID`,
`EventDate`, `UTCEventTime` разбираются в `Nullable` и дают NULL, если значение
не той природы. Остальные сорок две достаются обычными типами. Соотношение
цены и пользы: единственный производитель топика — собственный генератор,
сериализующий из контракта по объявленным типам, поэтому неверный тип может
прийти только из руки, а сорок семь проверок на NULL превратили бы матвью в
простыню. Пять выбраны по последствию: это идентификаторы события, визита и
посетителя, дата партиции и метка времени, по которой события упорядочиваются
внутри сессии, — порча любой отравляет всё ниже по течению. `CounterID`
формально тоже входит в ключ сортировки, но на стенде он константа, и NULL там
взяться неоткуда.
Присутствие иначе и не проверить. `Nullable`
в ClickHouse не оборачивает составные типы: `Nullable(Array)` запрещён, а
`Array(Nullable(T))` при пропавшем ключе даёт пустой массив, неотличимый от
пустого по смыслу. Таких колонок в контракте двенадцать из сорока семи.
Типизированная Kafka-таблица не используется. Режим `kafka_handle_error_mode =
'stream'` не используется тоже: при чтении байтами на входе нечему ломаться, и
ошибке разбора взяться неоткуда.
Отсюда ограничение на форму выражений разбора: они собираются только из функций,
которые не бросают исключений. `JSONExtract` и родственные возвращают значение по
умолчанию или NULL, но не падают, — и правило репозитория «грязные записи не
валят пайплайн» держится теперь именно на этом. Исключение в матвью не ошибка
формата, его не перехватит никакой режим Kafka-движка: вставка упадёт, офсеты не
закоммитятся, блок пойдёт читаться снова. Ломается это громко и чинится без
потерь — поправил матвью, потребление продолжилось с некоммиченного офсета, — но
пока не починено, топик стоит. Поэтому приведение `Nullable` к необнуляемому типу
и любая арифметика в этих выражениях живут за предикатом, который NULL уже отсёк.
Этим решение снимает ограничение, записанное в постановке #37: «ошибки разбора
должны рождаться на шаге Kafka-движка». Оно ставилось как условие достижимости
критерия #43 про громкую ошибку на опечатку в имени поля — критерий достижим и
без него, средствами матвью.
Требование строгого приёма из раздела 6 спеки остаётся в силе, меняется его
механизм: настройка формата `input_format_skip_unknown_fields` при чтении
байтами беспредметна, раскладки по полям на этом шаге нет вовсе.
## Почему
Типизированный чтец не отдаёт сырьё. При `kafka_handle_error_mode = 'stream'`
виртуальные колонки `_raw_message` и `_error` заполняются только при ошибке
разбора и пусты у разобранных сообщений. Один типизированный чтец оставил бы в
STG сырьё исключительно от брака: слой сырых строк хранил бы всё, кроме того,
что доехало.
Слои идут цепочкой. Kafka → STG → ODS → DDS → DM, каждый читает предыдущий.
Отвергнутая схема с двумя чтецами одного топика — сырым для STG и типизированным
для ODS — в бою обычна и дала бы строгий приём даром, средствами самого движка.
Но ODS перестал бы быть надстройкой над STG и стал бы вторым входом с шины, а на
стенде, где слои и есть предмет изучения, это дороже сэкономленного. Побочная
выгода скромнее, чем кажется на первый взгляд: слой сырья при двух чтецах
существовал бы точно так же, но SQL разбора не существовал бы нигде, и для
переобработки дня X его пришлось бы писать заново. В цепочке он уже есть в
матвью, и ручная вставка получается его копией.
Слой сырья ничего не проверяет, и формат чтеца выбран под это. Соседний вариант,
`JSONAsString`, требует, чтобы каждое сообщение было корректным объектом JSON, и
на некорректном падает: потребление встаёт, хотя правило репозитория гласит, что
грязные записи не должны валить пайплайн. Лечится это двумя способами — включить
на чтеце режим обработки ошибок или не проверять на входе вовсе. Второе честнее:
частичная проверка в слое, чья работа — не проверять, спорит сама с собой, а
настоящий разбор всё равно идёт ниже. При `RawBLOB` ломаться нечему, доезжает
любое непустое сообщение, каким бы мусором оно ни было (про «непустое» — там же,
в «Что проверено»), и оба класса брака разбираются в одном месте.
Архив нужен буквальный и читаемый глазами. `RawBLOB` — это про способ чтения, а
не про тип колонки: на диске лежит обычный `String` с текстом события, и менти
открывает его в обычном клиенте и читает. В этом и смысл слоя — увидеть, что
реально пришло. Мусор при этом лежит в той же колонке, что и целые события, а не
в отдельной. Отвергнутый боевой вариант — собрать слой сырья на движке `Null`,
тогда данные не хранятся вовсе, а матвью работает чистым триггером. Отлаживать
так неудобно, поэтому здесь окно в трое суток, а не ноль.
Что при этом потеряно и чем закрыто. Настройка `input_format_skip_unknown_fields
= 0` ловила два случая: ожидаемое поле пропало и появилось лишнее, неизвестное.
Оба берёт на себя сверка набора ключей: опечатка в имени — это одновременно
пропавшее ожидаемое и появившееся лишнее, и сравнение множеств видит и то, и
другое. Она же единственная защита у колонок-массивов и она же ловит молчаливое
расширение контракта, когда в сообщении появляется поле, о котором хранилище не
знает. `Nullable`-разбор к этой работе отношения не имеет — он про тип, а не про
присутствие, и потому сужен до пяти колонок.
Цена решения. Контракт получает ещё два места: типы сорока семи колонок в
выражениях матвью и список тех же имён для сверки ключей — а список этот
повторён дважды, по разу на матвью. Раздел 1.4 спеки предупреждает, что,
повторяясь примерно в семи местах, они расходятся молча.
И сверка ключей эту цену покрывает не всю: она смотрит на ключи сообщения, а
не на выражения матвью. Опечатка в имени внутри `JSONExtract` даёт умолчание
типа — ноль, пустую строку, пустой массив, — и молчит она у сорока двух
обычных колонок ровно так же, как у двенадцати массивов. Громко ломаются
только пять опорных: у них разбор `Nullable` стоит в предикате, и опечатка
уводит в таблицу ошибок все строки до единой. Остальные сорок две сторожит
сверка разобранного события против сырого текста — разовый опыт при
исполнении #43, а не постоянная проверка; сила его в том, что выражения
сверки собираются из контракта, а выражения матвью написаны руками по
описанию выгрузки, и одна опечатка в двух местах не повторяется.
Второе — разбор функциями дороже разбора форматом. На объёмах стенда это
несущественно; если станет заметно, сорок семь вызовов сворачиваются в один
`JSONExtract` в именованный кортеж, и строка разбирается однократно.
## Что проверено
По документации ClickHouse через MCP Context7: основная сверка — 3 августа
2026 года, перепроверка после правок — 5 августа. Датировка важна: 5 августа
утверждение про `Nullable(Tuple)` развернулось на противоположное.
При режиме `stream` движок отдаёт `_raw_message` и `_error` только для
сообщений, которые не разобрались, и оставляет их пустыми для разобранных.
Отсюда весь довод о том, что типизированный чтец не может наполнить слой сырья.
Составные типы `Nullable` не оборачивает: `Nullable(Array)` и `Nullable(Map)` не
поддерживаются, а `Nullable` внутри них — да. Отсюда слепота `Nullable`-разбора
на двенадцати колонках и необходимость сверки ключей. Оговорка про кортеж:
`Nullable(Tuple)` в ClickHouse всё-таки есть, но за настройкой
`enable_nullable_tuple_type` и в статусе беты; на довод это не влияет — кортежей
в контракте нет. `Nullable` внутри кортежа разрешён без всяких флагов, поэтому
запасной вариант со свёрткой в именованный кортеж жив.
Оттуда же: у Kafka-движка есть третий режим обработки ошибок —
`dead_letter_queue` с записью в системную таблицу. Он отвергнут независимо от
остального: спека требует свои `*_errors`, системная таблица их не заменяет.
Форматы `RawBLOB` и `LineAsString` в ClickHouse есть, и Kafka-движок
поддерживает все форматы. `RawBLOB` читает вход в одно значение и рассчитан на
таблицу с единственным полем `String` — отсюда ограничение на форму чтеца.
Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу — блок она видит до разрезания по шардам. Проверено
владельцем на рабочих проектах; на стенде проверяется вместе с матвью разбора,
то есть при исполнении #43.
Пункт про `RawBLOB`, стоявший в списке ниже первым, закрыт при исполнении #37,
на стенде 5 августа 2026 года: формат даёт ровно одну строку на каждое непустое
сообщение, пачка продюсера границы сообщений не стирает, запасной `LineAsString`
не понадобился.
Тогда же нашлась и граница обещания — запись с пустым значением и
запись-надгробие не дают строки вовсе; из-за неё в «Решении» и в «Почему»
приписано слово «непустое». Замер целиком — в [доке
хранилища](../architecture/storage.md), раздел «Что проверено».
Остальные четыре закрыты при исполнении #43, на стенде 7 августа 2026 года,
ClickHouse 26.3.17.56. Все четыре ответили так, как ждала постановка:
- `isValidJSON('123')` возвращает единицу — скаляр законный JSON, и первый
класс брака поэтому проверяет именно объект, а не валидность;
- `JSONExtractKeys('123')` возвращает пустой массив, и он же приходит от
вовсе не-JSON. Значит скаляр проваливает и сверку ключей — отсюда
обязательный порядок классов. Раз `isValidJSON` объект от скаляра не
отличает, первый класс держит `JSONType`: она возвращает `Object` у объекта,
`Int64` у скаляра `123` и `Null` у вовсе не-JSON и у пустой строки —
исключения не бросает ни в одном случае, то есть годится в предикат;
- `JSONAsString` на некорректном вводе падает, а не пропускает строку: код 117
`INCORRECT_DATA`, «JSON object must begin with '{'». Падает и на скаляре
`123`. На этом стоит отказ от него в пользу `RawBLOB`;
- форма именованного кортежа с `Nullable`-членами работает и годится в
запасной вариант: `JSONExtract(raw, 'Tuple(WatchID Nullable(UInt64), …)')`
даёт NULL в тех членах, что не разобрались, обращение по имени члена
доступно, а на не-объекте кортеж выходит целиком из NULL и исключения нет.
Тем же заходом нашлось то, о чём никто не спрашивал, и оно оказалось
блокирующим. **`JSONExtract` с типом `DateTime` не разбирает ISO-8601 с
суффиксом зоны.** На проводе `UTCEventTime` уезжает как
`2026-06-01T12:34:56Z` (спека генератора, раздел 4), а
`JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')` отдаёт на такой
строке NULL — то есть все события до единого уходили бы в брак с классом
`key_field_unparsed`. Ни `DateTime64`, ни `DateTime('UTC')` суффикс тоже не
берут; без `Z` та же строка разбирается. Спека генератора обещала обратное
(«принимает ISO без плясок») — обещание было ошибочным и исправлено тем же PR.
Настройка `cast_string_to_date_time_mode = 'best_effort'` дела не меняет:
с ней суффикс берёт `CAST`, а `JSONExtract` по-прежнему отдаёт NULL — то есть
внутренний разбор `JSONExtract` её не слушает. `EventDate` уезжает как
`2026-06-01` и разбирается `JSONExtract` без оговорок.
Разбор метки времени идёт
`parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), '%Y-%m-%dT%H:%i:%SZ', 'UTC')`
— по буквально названному формату, а не через `parseDateTimeBestEffort`.
Обе функции ISO-8601 понимают и обе в варианте `*OrNull` отдают NULL вместо
исключения, то есть годятся в предикат. Выбран точный формат потому, что
широта здесь работает против строгого приёма: `parseDateTimeBestEffort` на
непонятной строке не краснеет, а достраивает недостающее. Измерено 7 августа
2026 года — обрезанное `20:00:21` он превращает в `2026-01-01 20:00:21`,
подставив текущий год и первое января; голая дата `2026-05-31` становится
полуночью; строка цифр читается числом эпохи. Такое сообщение прошло бы
строгий приём с тихо неверным временем — ровно с той порчей, ради которой
класс `key_field_unparsed` и заведён. Разбор по названному формату отдаёт на
всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не
нужна вовсе, а платится за неё отключённой проверкой.
Третий аргумент — имя пояса, `'UTC'` — пришёл с конвенцией #63; правило и его
довод — [конвенция часовых поясов](../architecture/storage.md).
Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья
модельного дня (день залит дважды) точный формат разобрал метку у каждой, и
ни на одной не разошёлся с `parseDateTimeBestEffort`. Различаются они только
на порче.
Оговорка к обнуляемому разбору даты, измеренная там же: `Nullable(Date)`
даёт NULL на строке, которая датой не является вовсе («мусор»), и на числе,
но невозможную дату `2026-13-99` молча приводит к `1970-01-01`. То есть
класс `key_field_unparsed` ловит порчу типа, а не порчу значения внутри типа.
+73
View File
@@ -0,0 +1,73 @@
# ADR 0006. Имена объектов хранилища: суффикс вида
Дата: 4 августа 2026 года. Статус: принято.
## Решение
Имя объекта в ClickHouse заканчивается тем, что это за объект: `_rep` —
локальная таблица шарда, `_dist` — `Distributed` поверх неё, `_kafka` — чтец
топика, `_file` — чтец файла, `_mv` — материализованное представление, `_v` —
обычное представление.
Суффикс `_file` пришёл с подложкой под словарём товаров
([ADR 0012](0012-dictionary-home.md)).
Суффикс носит каждый физический объект, поэтому голого имени у таблицы не
существует: запрос к `stg.hits_raw` даёт ошибку «нет такой таблицы».
Единственное исключение — словари: воплощение у них одно, шардировать нечего, и
суффикс ничего не различал бы. В документах и разговоре голое имя означает
сущность, у которой этих объектов несколько.
Конвенция применена к мастер-спеке тем же решением: `stg.kafka_hits` стал
`stg.hits_raw_kafka`, `dds.v_event` — `dds.event_v`, витрины `dm.v_*` —
`dm.*_v`. Полная таблица суффиксов и следствия для DDL — в
[доке хранилища](../architecture/storage.md).
## Почему
Главный довод — как ошибается забытый суффикс. Распространённая конвенция, где
голое имя означает локальную таблицу, а распределённая получает `_all`,
ошибается молча: запрос к голому имени вернёт данные одного шарда и никак об
этом не скажет. Правило спеки «проверки и контрольные суммы — только по
`Distributed`» такую тишину не переживает: контрольная сумма, посчитанная по
половине кластера, выглядит как честное число. При суффиксе вида голого имени
нет вовсе, и та же ошибка становится громкой.
Второй довод — что группируется в списке по алфавиту. Суффикс группирует объекты
по сущности: все четыре объекта топика `hits` стоят рядом, потому что различаются
хвостом. Префиксный стиль, которым пользовался стенд-предшественник (`kafka_*`,
`mv_*`, `v_*`), группирует по технологии, и объекты одной сущности
расползаются по алфавиту. В хранилище, где у одной сущности живёт по три-четыре
воплощения, полезнее первое. Довод про дерево в клиенте и про `ORDER BY name`:
порядок выдачи `SHOW TABLES` документация не оговаривает, так что на него здесь
опираться нельзя.
Третий — преемственность: `_rep` и `_dist` уже используются владельцем в других
хранилищах на ClickHouse, и общий словарь между стендами стоит больше, чем
локальная стройность.
Отвергнуты, кроме `_all`: префиксный стиль предшественника — он не покрывает
пару локальная/распределённая, для неё префикса просто нет; голое имя как
`Distributed` с суффиксом `_local` у локальной — привычное имя ведёт в
правильную таблицу, но конвенция расходится с другими стендами владельца;
раскладка пары по разным базам (`stg` и `stg_dist`) — удваивает число баз в
каждом слое и разъезжается с таблицей слоёв спеки.
Цена решения — правка принятой спеки задним числом. Имена представлений и
витрин переехали с префикса на суффикс, хотя сами объекты спроектированы не
полностью и появятся только на этапах 4 и дальше. Размен принят осознанно:
конвенция, введённая после того, как по ней написан первый слой, обходится
дороже.
## Что проверено
Проверять здесь нечем — это соглашение, а не поведение системы. Вместо проверки
конвенция прогнана по карте таблиц спеки, раздел 7: суффикс выводится для всех
объектов слоёв STG, ODS, DDS и DM, включая пары локальная/распределённая,
представления и матвью. Единственным объектом без выводимого суффикса оказался
словарь `products` — отсюда исключение в решении.
Первый прогон был неполным: четыре витрины из восьми остались с префиксом, и
заметило это холодное ревью, а не автор. Имена приведены в порядок 5 августа
2026 года. Урок не про имена: «прогнал по документу» — такое же утверждение,
как утверждение о поведении системы, и проверять его надо так же.
+209
View File
@@ -0,0 +1,209 @@
# ADR 0007. Доступ к ClickHouse: пользователи файлом, доверие нод секретом
Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader`
и пользователь `grafana` добавлены 21 августа 2026 года (#114), права
`bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150).
## Решение
У кластера семь пользователей и шесть ролей.
- `default` — с паролем, только служебный: проверки здоровья нод и работа
изнутри контейнеров. Приложения им не ходят.
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходят рабочие
даги Airflow.
- `lifecycle` с ролью `lifecycle_owner` — создание и удаление только
прикладных баз и объектов. Им даги жизненного цикла применяют DDL и
пересоздают мир ([ADR 0013](0013-world-lifecycle.md)).
- `bi` с ролью `bi_reader` — чтение слоёв ODS, DDS и DM, а также справочников
`dic`. Им ходит Superset; его запросы обращаются к публичным `_v`, хотя права
шире.
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
человек подключается снаружи, из своего клиента.
- `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и
системных таблиц, `CLUSTER` и `REMOTE`. Им ходит Grafana, и больше никто.
- `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им
словарь читает свою подложку, и больше он никем не используется
([ADR 0012](0012-dictionary-home.md)).
Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения
на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок
внешнего источника. Без права на системные таблицы клиент вовсе не открывает
соединение.
Пользователи, роли и права объявлены файлами настройки сервера, а не
запросами SQL. Файл монтируется на обе ноды и лежит в git. Пароли живут
в `.env` и попадают в настройку подстановкой по имени переменной; значения
собраны по конвенции «слово-число-слово», потому что пароль `analyst`
человек набирает руками.
Ноды доверяют друг другу по общему секрету, объявленному в описании
кластера. Учётные данные по репликам не расписываются.
Сетевых ограничений на `default` нет: порты стенда и так открыты только на
`127.0.0.1`, а внутри сети Compose дверь заперта паролем.
Раздел README «Состав и доступ» описывает, кто каким пользователем ходит и где
живут пароли; здесь остаются решение и доводы. Отдельного справочника по
доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция
чтения через `_v` описана в доке хранилища.
## Почему
Стенд учит не администрированию доступа, а чтению отказа в правах: человек
должен узнать в бою картину, которую видел здесь, и понимать, чего просить.
Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из
описания. Поэтому пользователей мало, имена у них говорящие. В бою BI обычно
читает витрины и модельный слой. ODS ему могут открыть для расследования или
сверки. Стенд оставляет этот доступ постоянным, но сохраняет две крупные
границы: `bi` отделён от STG и от записи. Один пользователь может сверить всю
цепочку DM → DDS → ODS, не получая доступа к сырью и изменению данных.
Справочники видны всем читателям и никому на запись: зона входит в хранилище
сбоку, и её содержимым владеет файл репозитория, а не слой.
Ограничить `bi` одними `_v` поверх `Distributed` можно только отдельным
маршрутом, учётной записью или копией данных. Эта механика не даёт нового урока.
Публичное представление остаётся договором чтения; границу прав оно не задаёт
([исследование](../research/2026-08-25-clickhouse-distributed-view-security.md)).
**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то
есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно —
`SELECT` на `dic`, и хост источника локальный.
**Своя роль дашбордам.** Панели мониторинга живут не на витринах, а на
`system.query_log`, частях MergeTree и очереди распределённых DDL — и читают
их через `clusterAllReplicas`, потому что системные таблицы у каждой ноды свои
([ADR 0002](0002-monitoring-scope.md)). Для этого нужны `SELECT` на `system.*`
и `REMOTE`; контракт роли также включает `CLUSTER` для запросов `ON CLUSTER`.
Такого набора не было ни у одного читателя: у обеих читающих ролей из
системных таблиц только `system.settings`. Догрузить
недостающее в `analyst_reader` отвергнуто: аналитик получил бы системные
таблицы и `REMOTE` заодно, а в `system.query_log` запросы Grafana стали бы
неотличимы от человеческих — ровно там, где дашборд «Запросы» и должен их
различать. Пускать Grafana под `etl` отвергнуто сразу: читатель не получает
права писать. Осталась своя роль по здешней общей схеме — у каждого
потребителя она своя.
**Секрет, а не учётные данные по репликам.** У каждой реплики в описании
кластера своя учётка — вписанная явно или подразумеваемая, и тогда это
беспарольный `default`. Подключение к соседней ноде идёт от неё, а не от
того, кто задал вопрос. Значит, стоит `default` получить пароль — и без
правки описания кластера межнодовые запросы встают. Починить это можно двумя
способами, и они различаются не удобством. С учётными данными по репликам вся
межшардовая работа идёт от одной общей учётки: на своей ноде права
применяются, на соседней — нет. Ролевая модель тогда наполовину декорация, а
это хуже её отсутствия: она утверждает разграничение, которого не существует.
Общий секрет передаёт на соседа личность инициатора, стоит одной строки в
описании кластера и не размножает пароль по репликам. Шифрование тут ни при
чём; в соседнем `clickhouse-learning-cluster` выбран как раз путь с учётными
данными по репликам, и там пароль вписан в каждую реплику каждого из трёх
кластеров.
Довод подтверждён замером 13 августа 2026 года: на соседнем шарде сохранился
пользователь-инициатор, поэтому там применились его права. Результат замера —
в разделе «Что проверено».
**Файлы, а не SQL.** Объявленное файлом не оставляет состояния в томах:
источник истины — файл в git. Стенд считает `access.xml` и переданные через
`.env` значения настройкой при создании контейнера; отдельного способа
применять их на живом стенде нет, потому что уроки не меняют базовую модель
доступа. Порядок применения изменений — в разделе README «Состав и доступ».
Оба варианта с SQL — локальное хранилище доступа с `ON CLUSTER` и
реплицированное в keeper — делают пользователей состоянием тома: `.env` на
них больше не влияет, и появляется расхождение того же сорта, которое README
уже описывает для Postgres и Grafana. Цена выбора известна: объявленных файлом
пользователей нельзя менять запросами. Она принята как упрощение — сломать
модель случайно труднее.
**Что ещё отвергнуто.** Оставить всё на беспарольном `default`
— цена нулевая, но и рассказать по итогам нечего. Раздать всем пароль без
ролей — дёшево и показывает ровно тот антипаттерн, за который ругают в бою:
все под одним админом. Вынести доступ в лекцию, как репликационную
эксплуатацию, — довод «отдельный операционный домен» здесь не работает:
у репликации это живые процессы, учения и runbook, а тут несколько десятков
строк настройки и ноль эксплуатации. Запереть `default` ещё и по сети —
второй замок на ту же дверь.
**Отложено явно**: TLS и защищённые межсерверные соединения, настоящее
хранилище секретов, внешние поставщики учётных записей, квоты и профили под
пользователя, политики строк, аудит запросов.
## Следствия
Новые таблицы ODS, DDS и DM не требуют отдельного гранта для `bi_reader`.
Явный договор `_v` защищает потребителя от устройства хранения и случайных
новых колонок.
DDL для матвью приёма выполняется от имени `lifecycle`. Это видно в журнале
запросов, а при создании ClickHouse проверяет у этой учётки чтение источника и
вставку в цель. Так отдельная учётка показывает границу между редким
управлением структурой и повседневной записью `etl`, не выдавая рабочим дагам
право удалять базы.
Лаба «сломать права» вариантом с файлами не закрывается: создать через SQL
новую роль и нового пользователя ничто не мешает, и новую роль разрешено
выдавать даже пользователю, объявленному файлом. Недоступно обратное —
править объявленных файлом. Всё, что лаба создаст, живёт в томе и убирается
`make clean`.
## Что проверено
Сверено 9 августа 2026 года по документации ClickHouse через MCP Context7 и
на живом стенде 26.3.17.56.
- В `system.clusters` у каждой реплики своя колонка `user`; на стенде там
`default`. Отсюда весь довод про общий секрет.
- Секрет кластера аутентифицирует серверы хешем от соли, нонса, секрета и
запроса и позволяет назначить пользователя, от которого запрос выполняется
на соседней ноде.
- Роли и права объявляются в файле настройки пользователей наравне с самими
пользователями; SQL-хранилище доступа для этого не требуется.
- Хранилище пользователей из файла — только для чтения: попытка изменить
такого пользователя запросом даёт `ACCESS_STORAGE_READONLY`.
- Роль, созданную через SQL, разрешено выдавать пользователю из файла —
отдельное улучшение ClickHouse 2025 года, в 26.3 оно есть.
- Подстановка значений из переменных окружения — общий механизм файлов
настройки ClickHouse.
- Весь DDL репозитория идёт через `IF NOT EXISTS` (файлы `sql/ddl/`).
Права роли `monitoring_reader` сверены 21 августа 2026 года по документации
ClickHouse через MCP Context7 (#114).
- Системные таблицы выдаются обычным `SELECT`: в документации это
`GRANT SELECT ON system.query_log`, значит `SELECT ON system.*` и есть «все
системные таблицы». Отдельного права на них нет.
- `GRANT CLUSTER ON *.*` — документированная форма права на запросы
`ON CLUSTER`; текущий запрос `clusterAllReplicas` его не использует.
- `clusterAllReplicas` документация держит в одном ряду с `remote`,
`remoteSecure` и `cluster`: это табличная функция, которая берёт имя
кластера из `remote_servers` и идёт на каждую его реплику. Отсюда `REMOTE` —
без права на удалённое обращение запрос дальше своей ноды не пойдёт, а
панели ADR 0002 читают `system.*` именно так.
Набор проверен на живом стенде 21 августа 2026 года (#114): пользователь
`grafana` из Grafana выполнил
`SELECT count() AS replicas FROM clusterAllReplicas('clickstream_cluster', system.one)`
и получил 2 — прав хватает, чтобы прочесть системную таблицу с обеих нод.
Проверено на живом стенде 13 августа 2026 года, ClickHouse 26.3.17.56.
- Секрет кластера принимается внутри `remote_servers` в виде
`<secret from_env="CLICKHOUSE_CLUSTER_SECRET"/>`. Запрос от `etl` через обе
ноды выполнился на каждой под пользователем `etl`.
- `from_env` работает у элемента `password`: `etl`, `lifecycle`, `bi` и
`analyst` вошли с паролями из окружения и получили свои роли.
- При запросе от `bi` через Distributed на второй ноде `system.query_log`
показал `user = bi` и `initial_user = bi`. Значит, на соседнем шарде
применяются права инициатора, а не общей учётной записи.
- Права на отсутствующие базы `dds` и `dm` приняты. До создания баз они видны
в `SHOW GRANTS FOR etl_writer`; временная таблица в `dm` подтвердила право
`bi` на чтение после появления базы.
Расширение `bi_reader` проверено на живом стенде 25 августа 2026 года (#150):
- `SHOW GRANTS FOR bi_reader` содержит `SELECT` на ODS, DDS и DM;
- `bi` прочитал одинаковое число строк из `ods.event_v` и `dds.event_v`;
- чтение `stg.hits_raw_rep` завершилось ошибкой `ACCESS_DENIED`;
- представление над `Distributed` с `SQL SECURITY DEFINER` на удалённой ноде
сохранило исходного пользователя `bi`. Причина и отклонённые варианты
разобраны в [исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
+204
View File
@@ -0,0 +1,204 @@
# ADR 0008. Приём заказов: пакетный забор слепка, инициируемый Airflow
Дата: 12 августа 2026 года. Статус: частично заменено
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
`dds.order` с `argMax` — это ADR 0010. Отдельно от него, решением #93 от
17 августа 2026 года, производитель и потребитель слепка разъехались по разным
дагам; раздел «Решение» описывает построенное. Действующая форма STG → ODS
описана в [спецификации приёма заказов](../architecture/orders/ingestion.md).
## Решение
Топик `orders` читает Kafka-таблица формата `RawBLOB` — та же форма чтеца, что
у событий ([ADR 0005](0005-event-ingestion.md)). Матвью к ней не привязана.
Сырьё забирает пакетный шаг, которым управляет Airflow: он вставляет
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
заменяет партицию дня в `ods.order_snapshot`.
Производитель и потребитель слепка — разные даги. Работник пульта мира играет
день и отправляет слепок, а забирает его отдельный даг `orders_ingest`; зовёт
забор сам работник ждущим `TriggerDagRunOperator` и зеленеет только вслед за
ним. Связывает две половины не общий даг, а этот дождавшийся успех: зелёный
работник значит, что забор отработал. Уточнение со сдвигом
отправки, решённым позже (#71, [слепок и его
доставка](../architecture/orders/snapshot.md)): работник, играющий день D, в
штатном прогоне кладёт слепок дня D−1 — слепок предыдущего дня, а не
сыгранного. После падения между шагами в топике может ждать и хвост прежних
слепков; забор принимает всё приехавшее.
Прямое чтение из Kafka-движка требует двух настроек, и вторая не очевидна:
`stream_like_engine_allow_direct_select = 1` разрешает читать чтеца запросом,
а `kafka_commit_on_select = 1` на самой таблице заставляет запрос коммитить
офсеты. По умолчанию прямое чтение их не коммитит — без этой настройки каждый
прогон забирает один и тот же слепок заново.
Матвью к чтецу не привязана не по вкусу, а по устройству: при привязанной
матвью прямое чтение остаётся запрещённым независимо от первой настройки. Два
способа приёма взаимоисключающи по построению, и выбрать половину каждого
нельзя.
У топика **одна партиция**, а чтец объявлен **только на `clickhouse-01` и без
`ON CLUSTER`**. Пишет пакетный шаг по-прежнему в распределённую таблицу, так
что раскладка по шардам не меняется: ключ у сырья прежний, `cityHash64` сырой
строки.
Слои от этого получают разные роли, и каждая своя:
- `stg.orders_raw` — байты как приехали, срок жизни и метаданные доставки как
у сырья событий; повторная заливка дня честно удваивает строки, как и там;
- `ods.order_snapshot` — типизированный слепок дня, идемпотентный **заменой
партиции** `snapshot_date`, ровно как обещает раздел 2 мастер-спеки;
- `dds.order` — дедуп до последней версии через `argMax`, без изменений.
Сенсора дневного батча нет. Ждать нечего: забор дёргает сам отправитель
слепка, когда отправил.
## Почему
**Слепок — не поток, и приём обязан это признать.** События приезжают
непрерывно, и матвью, тянущая их на лету, — честная форма для непрерывного.
Заказы приезжают раз в модельный день целой выгрузкой окна изменяемости; у
такой доставки есть начало и конец, и забирать её уместно по команде, а не
подписью на бесконечность. Стенд от этого получает не два оттенка одного
приёма, а два разных режима — push и pull, — и каждый стоит там, где ему место
по природе источника. Именно это сравнение раздел 12 мастер-спеки и заказывал
опорной точкой; прежняя его формулировка противопоставляла байтового чтеца
типизированному, то есть две формы одного и того же приёма, и переписана.
**Почему не типизированный чтец прямо в `ods.order_snapshot`.** Он давал бы
строгий приём средствами движка и живое сравнение двух чтецов даром, но ODS
перестал бы быть надстройкой над STG и стал бы вторым входом с шины. Ровно эту
схему ADR 0005 отверг для событий, и повод здесь тот же: на стенде слои и есть
предмет изучения.
**Почему не матвью из сырья в ODS, как у событий.** Тогда `ods.order_snapshot`
получил бы те же свойства, что `stg.orders_raw`: «как приехало», без дедупа,
дубли при повторной заливке законны. Два слоя подряд с одной ролью — один
лишний. Хуже того, обещание идемпотентности из раздела 2 повисло бы ни на чём:
матвью партиций не заменяет, а у ODS, в отличие от сырья, срока жизни нет, и
удвоение слепка жило бы вечно. При пакетном шаге каждый слой отрабатывает
своё, а замену партиции делает тот, кто данные и принёс.
**Почему чтец остался байтовым.** Пулл снял возражение про второй вход с шины,
и типизированный чтец снова стал допустим — но тогда между двумя приёмами
стенда менялись бы сразу две переменные, и сравнение перестало бы читаться.
Меняется одна: push против pull. Вдобавок `RawBLOB` сохраняет за слепком то же,
что даёт событиям, — колонку, которую менти открывает в обычном клиенте и
читает глазами, и возможность переразобрать сырьё, не переигрывая день.
Свойства этого формата уже сняты живыми запросами при исполнении #37 и #43;
менять его здесь значило бы платить второй раз за уже купленное.
**Почему одна партиция и один чтец.** Вторая партиция ничего не покупает:
слепок дня — порядка полутора тысяч строк, параллелизм не нужен. А урок «какая
нода читала топик» при пулле мёртв в любом случае — читает та нода, которую
спросили. То есть вторая партиция продаёт единственный настоящий риск схемы:
половина слепка застревает у ноды, к которой запрос не пришёл. Одна партиция
риск смягчает, но не снимает — брокер отдаст её любому из двух потребителей;
снимает его единственный чтец. У Airflow подготовлено одно подключение, к
`clickhouse-01`, — там чтецу и место.
Объявление без `ON CLUSTER` — не оговорка к конвенции, а её первое осознанное
исключение: у пулла один тянущий по определению. Заодно это контрпример к
рефлексу «везде `ON CLUSTER`»: приставка не ритуал, а решение.
**Почему не `Distributed` поверх Kafka.** Один запрос к распределённой таблице
над двумя чтецами осушил бы обе ноды разом и снял бы вопрос о партициях.
Механически это, скорее всего, работает — `Distributed` просто просит каждый
шард выполнить локальное чтение по имени таблицы, — но документация о такой
связке молчит: ни поддержки, ни запрета. Цена молчания высока. Офсеты
коммитятся на каждом шарде в момент чтения, а вставка идёт следом на
инициаторе: упала вставка — потеряны обе половины, а не одна. Недоступный шард
даёт худший из возможных исходов — тихо приехавшую половину слепка. Чинить
такое пришлось бы чтением исходников. И учебная цена своя: менти обязан
расшифровать конструкцию, которой нет ни в документации, ни в бою, и получает
за это трюк. При одной партиции и одном чтеце она не нужна вовсе.
**Что отвергнуто ещё.** Сенсор дневного батча из раздела 9 мастер-спеки: у
топика нет сигнала «всё», и сенсор ловил бы момент, которого не существует, —
а раз забор зовёт сам отправитель слепка, ждать нечего по построению. Лаба,
поднимающая типизированного чтеца во второй группе потребителей ради того же
сравнения: она понадобилась бы, останься сравнение невыполненным, но push
против pull даёт его живым и постоянным.
## Следствия
Урок про виртуальные колонки — «какая нода читала топик, меняется между
прогонами» — остаётся целиком за событиями. У заказов `consumer_host` всегда
один и тот же, и это честная разница двух режимов, а не потеря: при пулле
читает тот, кого спросили.
Гарантий приёма у заказов не больше, чем у событий, и граница проходит по
живому. Успешное чтение короткой порции оставляет непрочитанный хвост в
топике — он дождётся следующего забора. А отказ после чтения теряет саму
порцию: офсеты закоммичены в момент чтения, часть строк могла лечь на шарды,
и повторное чтение вернёт ноль. Позицию мира это не двигает, поэтому день
сыграется и уедет заново; в сырье он тогда окажется частичным дублем, а
старый хвост, ушедший той же порцией, не вернётся.
Этап 3 забирает у этапа 5 первый настоящий даг. Раздел 9 мастер-спеки отдавал
даги этапу 5 целиком; приём заказов без дага не существует, поэтому порядок
меняется. `etl_pipeline` остаётся за этапом 5. Позднее сдвинулся и он:
резолюция #134 родила его этапом 4 — [дневным конвейером
трансформаций](../architecture/etl.md), — а этапу 5 оставила партиционную
переобработку.
Расхождения с мастер-спекой, внесённые тем же коммитом: раздел 6 (чтец заказов
живёт на одной ноде и без матвью), раздел 7 (развилка закрыта, у `orders` одна
партиция), раздел 9 (сенсор снят, даг переехал на этап 3), раздел 12
(сравнение приёмов переформулировано).
## Что проверено
Документация ClickHouse проверена через MCP Context7 12 августа 2026 года.
Предел порции одного опроса Kafka дополнительно снят 16 августа на локальном
ClickHouse `26.3.17.56`.
- Прямое чтение из движков-очередей (Kafka, RabbitMQ, FileLog) запрещено
начиная с версии 21.12 и открывается настройкой
`stream_like_engine_allow_direct_select`.
- При привязанной матвью прямое чтение остаётся запрещённым и с этой
настройкой. Отсюда вывод, что два способа приёма взаимоисключающи.
- Прямое чтение офсеты по умолчанию **не** коммитит; коммит включается
настройкой `kafka_commit_on_select` на самой таблице. Это тот подводный
камень, который молчит на первом прогоне и вылезает на втором.
- Прямое чтение возвращает одну порцию, полученную одним опросом Kafka. При
настройках стенда её предел — 65 409 сообщений, поэтому слепок порядка
полутора тысяч строк помещается с запасом.
- Про `Distributed` поверх Kafka документация не говорит ничего — ни
поддержки, ни запрета.
Забор проверен на живом стенде 18 августа 2026 года при исполнении #93 —
ClickHouse `26.3.17.56`, Airflow 3.3.0. Три вопроса, оставленные этим ADR
реализации, закрыты; заодно снят отказной путь. Обе настройки прямого чтения
доезжают до запроса, объявленные в конце `INSERT ... SELECT`: `system.query_log`
показывает у каждой вставки единицу.
- **Виртуальные колонки доставки при прямом чтении доступны все четыре.**
`_topic`, `_partition`, `_offset` и `_timestamp_ms` читаются тем же
выражением, что в матвью приёма событий, и метаданные в `stg.orders_raw`
заполнены: 1694 строки слепка дня 7 приехали с `orders`, партицией 0,
сплошными офсетами 0…1693 и непустой меткой брокера. Отдельного механизма
пуллу не понадобилось.
- **Одного чтения хватает и на слепок, и на разгон.** Пять заборов подряд
взяли 1694, 1730, 3446, 1694 и 5097 строк — каждый раз ровно столько,
сколько напечатал генератор. Числа сверх слепка объясняются сами: 3446 — это
свежий слепок плюс хвост, оставшийся от упавшего прогона, а 5097 — три
слепка разгона `days = 3`, уехавшие одной порцией.
- **Офсеты действительно коммитятся.** Второй забор подряд по пустому топику
вернул ноль строк, а офсеты следующего продолжились с 1694 — то есть
`kafka_commit_on_select` работает, и слепок не забирается заново.
- **Упавший забор не двигает мир.** Чтец снесли, и прогон работника покраснел
на триггере забора: `remember_played` ушла в `upstream_failed`, позиция
осталась прежней, а после починки тот же день сыгран заново, и ждавший хвост
уехал одной порцией вместе со свежим слепком. Чтения в этом опыте не было
вовсе — падать было нечему, — поэтому он показывает только несдвиг позиции.
Судьба уже прочитанной порции другая, и она описана в «Следствиях».
Осталось проверить при исполнении #94, на стороне ODS: что повторный разбор
того же `_load_id` не двоит версии заказа и не меняет `_load_ts` — критерии
целиком в [спецификации приёма заказов](../architecture/orders/ingestion.md),
раздел «Риски и проверка».
+176
View File
@@ -0,0 +1,176 @@
# ADR 0009. Пульт мира: работники, выключатель и канонический контейнер
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
## Решение
Ось модельного времени двигают даги пульта, и они двух родов.
**Работники** — без расписания и без паузы, запускаются руками:
- `world_next_day` — день пачкой; параметр «сколько дней» (умолчание 1, потолок
7) даёт разгон вперёд одним нажимом. Потолок — это и есть названное желание
«уедь на неделю сейчас», и ровно тот разгон, который забор заказов забирает
одним чтением ([ADR 0008](0008-order-ingestion.md));
- `world_live_day` — один день в темпе живого режима.
**Выключатель** — без своей работы: `world_live` несёт расписание около двадцати
пяти минут, создаётся на паузе и дёргает `world_live_day`
через `TriggerDagRunOperator`, дожидаясь его завершения. Снят с паузы — стенд
живёт день за днём; поставлен на паузу — мир встал на границе модельных суток.
Генератор задача зовёт через `DockerOperator`: тот поднимает образ
`clickstream-generator:local` — тот самый канонический контейнер, внутри
которого [спека генератора](../specs/2026-08-01-generator.md) обещает побайтовую
воспроизводимость, — в сети стенда, командой `batch` или `live`.
Позиция на оси живёт переменной Airflow, как обещает раздел 8 той же спеки.
**Ставит её тот, кто сыграл день** — работник, и только по успеху, значением
«последний сыгранный день + 1». Выключатель к переменной не прикасается. День
работник берёт из переменной, а не из логической даты прогона: календарь Airflow
к оси мира отношения не имеет.
Позднее [ADR 0013](0013-world-lifecycle.md) отдал Airflow начальную позицию и
состояния обслуживания: переменной нет до первого создания мира, `-1` означает
незавершённую очистку, `0` — чистый мир до генерации, а работники принимают
только позицию `8` и больше.
Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, —
**отложена, а не отвергнута**.
## Почему
**Работники без расписания, выключатели без работы.** Иначе кнопка паузы значит
две вещи разом: «мир не едет сам» и «даг выключен». Расписание на самом
работнике формально живёт с ручным запуском — пауза, по документации Airflow 3,
запрещает планирование, но ручной запуск разрешает, — только в списке дагов
работник выглядит выключенным, а нажимают его каждый день. У предшественника
стоит ровно эта форма: `world_next_day` с расписанием и на паузе, к ней
объясняющий комментарий в коде и сторож `assert_target_dag_not_paused` на случай
триггера в спящий даг. Разделение снимает и комментарий, и сторожа: работника
никогда не ставят на паузу, поэтому дёргать спящего некому.
**Канонический контейнер — условие обещания, а не вкус.** Детерминизм генератора
обещан внутри зафиксированного образа, и обещание держится только там. Поставить
пакет прямо в образ Airflow, как делал предшественник, здесь нельзя даже
механически: генератор требует Python 3.14, а `apache/airflow:3.3.0` несёт 3.13.
Собрать в образе Airflow второе окружение из того же `uv.lock` — можно, но это
второе место установки генератора, пересборка образа Airflow на каждую правку
мира и раскладка каталога товаров, повторённая монтированиями руками: образ
генератора делает эту работу сам. Заодно `DockerOperator` держит генератор
отдельной и заменяемой сущностью, как решил раздел 8 спеки, и показывает
приём, у которого есть боевой родственник — так в бою запускают шаг подом.
**Выключатель ждёт, и от этого зависит устойчивость.** Не ждущий выключатель
тикает по расписанию независимо от хода дня: живой день длится около двадцати
четырёх минут, а у работника `max_active_runs = 1`, поэтому лишние триггеры
скопились бы очередью прогонов, и мир потом промчался бы по ней без темпа. С
ожиданием каденцию задаёт сама длина дня, а расписание остаётся грубым тиком «не
пора ли снова»: пауза между днями равна остатку до следующего тика, до минуты.
**Ждём триггером, а не сенсором.** Различаются они тем, чем опознают прогон.
`TriggerDagRunOperator` с `wait_for_completion` ждёт тот прогон, который сам и
создал, — опрос идёт по его идентификатору. `ExternalTaskSensor` ждёт задачу
чужого дага **за конкретную логическую дату**: пришлось бы передать работнику
логическую дату и повторить её в сенсоре, то есть завести вторую бухгалтерию
времени сразу после того, как решение выше её запретило. Сенсор здесь и как урок
был бы поддельным: его дом там, где ждут события, которого сам не производишь, а
у пульта производитель и потребитель — свои же даги. Тем же доводом
[ADR 0008](0008-order-ingestion.md) снял сенсор дневного батча. Настоящая
механика показана и без него: родитель, ждущий чужого прогона, со ссылкой на
дочерний прогон в интерфейсе и явными состояниями отказа — упал день, покраснел
и выключатель. Это [ADR 0003](0003-dag-run-state.md) в действии: зелёный конец
графа не должен переживать отказ выше.
**Позиция ставится, а не увеличивается.** Правило дешевле сторожа. Если прогон
выключателя наложится на ручной, худшее — день сыгран дважды: номера событий
детерминированы, повтор схлопнет `ReplacingMergeTree`, а позиция останется
верной. Увеличение на единицу в том же случае молча проглотило бы день, и мир
получил бы дыру, которую никто не заметит. Ни пул как мьютекс, ни проверка «а не
идёт ли другой прогон» после этого не нужны.
Худший исход у правила всё-таки есть, и он мягкий: работник, кончивший позже,
ставит позицию по своему последнему дню и может отодвинуть её назад — так
бывает, если долгий живой день закончится после разгона на три дня вперёд.
Тогда часть дней переигрывается. Это дешевле дыры: переигранный день склеится,
пропущенный не найдётся никогда.
**Плата — сокет докера внутри Airflow.** Доступ к нему равен праву root на
машине, и пользователю `airflow` он открывается добавлением GID группы `docker`
в контейнер. GID у каждой машины свой, поэтому он — локальная настройка и живёт
в `.env` рядом с остальными такими, а не в описании стенда. Для локального учебного стенда размен принят —
тот же, что уже принят для пароля в trace-журнале пробника, — но плата
называется вслух и в README. Вторая плата мелкая: пока стенд живёт, из четырёх
слотов `parallelism` заняты два, ждущий выключатель и работающий контейнер.
Освободить слот ожидания умеет `deferrable`, но он требует компонент triggerer,
которого стенд не поднимает; заводить службу ради одного ожидания дороже
занятого слота.
Приросший к работникам забор заказов (#93, [ADR 0008](0008-order-ingestion.md))
делает из двух занятых слотов три: пока идёт забор, ждут выключатель и сам
работник, а третий слот занимает задача `orders_ingest`.
**Пакетная автоматика отложена, потому что своего желания у неё пока одно.**
«Шагни и стой» закрывает кнопка, «уедь на неделю сейчас» — параметр «сколько
дней», «живи, пока я смотрю» — выключатель. Ей остаётся «едь сам быстрее, чем
живёшь»: день за пять минут, часами, — каденция, которой у живого темпа нет
(ускорение сверх ×60 спека отвергла как перемотку). Желание правдоподобное, но
пока не названное, а форма известна и стоит тридцать строк: второй экземпляр
выключателя на второго работника. Отложить дешевле, чем завести и объяснять.
**Что отвергнуто ещё.** Пульт на `make` с позицией в файле: дёшево сегодня, но
раздел 8 спеки уже отдал позицию переменной Airflow, а этап 3 без дага не
существует вовсе ([ADR 0008](0008-order-ingestion.md)) — платить пришлось бы
дважды, и второй хранитель позиции разошёлся бы с первым. Один долгий прогон
`live` на много дней: поток без швов, но позиция двигается только по успеху всего
прогона, а такой прогон всегда обрывают — переменная не сдвинулась бы никогда, и
обрыв на десятом дне откатывал бы все десять. Цепочка на ресурсах Airflow 3
(работник выпускает событие, выключатель на него подписан): каденция вышла бы
точной и без ожидания, но снять выключатель с паузы недостаточно — цепочку надо
толкнуть первым ручным прогоном, а читателю разбирать петлю «работник → ресурс →
выключатель → работник». Цена расшифровки выше урока.
## Следствия
Этапы 3 и 5 зовут генератор тем же приёмом: пакетный забор слепка заказов
([ADR 0008](0008-order-ingestion.md)) прирастает к тому же работнику, а
`etl_pipeline` этапа 5 получает готовую обвязку `DockerOperator` и готовое
правило позиции. Позднее резолюция #134 родила `etl_pipeline` этапом 4 —
[дневным конвейером трансформаций](../architecture/etl.md) без генераторных
шагов: обвязка и правило позиции остаются работникам, а живой день дёргает
конвейер после установки позиции.
Расхождения, внесённые тем же коммитом: раздел 8 спеки генератора (позицию
двигал «только даг `next_day`»; теперь — всякий, кто сыграл день, значением
«сыгранный + 1») и раздел 1 там же вместе со словарём (живой день «не постоянный
фон»; теперь фон у него есть, и у фона есть выключатель).
## Что проверено
По документации Airflow 3 через MCP Context7 и живым замером образа, 13 августа
2026 года.
- Пауза дага запрещает планирование, но **разрешает ручной запуск**
(`core-concepts/dags.rst`).
- `TriggerDagRunOperator` с `wait_for_completion` опрашивает прогон, который сам
создал, по его идентификатору; без этого флага `allowed_states`,
`failed_states` и `poke_interval` не действуют вовсе. Вариант `deferrable`
требует компонент triggerer.
- `ExternalTaskSensor` ждёт задачу чужого дага за конкретные логические даты;
сдвиги задаются `execution_delta` и `execution_date_fn`.
- Сенсор в режиме `poke` держит слот всё время, в режиме `reschedule` —
только на время проверки.
- У `DockerOperator` `network_mode` принимает имя пользовательской сети, есть
`auto_remove` и `skip_on_exit_code`.
- Образ `apache/airflow:3.3.0` несёт Python 3.13.14 (замер запуском образа);
генератор требует `>=3.14,<3.15`.
- Сокет докера на машине стенда — `root:docker` с правами `660`, GID группы 127;
пользователь образа Airflow — `uid=50000(airflow) gid=0(root)`, в группе
`docker` его нет. Значит доступ открывается добавлением GID в контейнер, а
правка прав на хосте не нужна.
Осталось проверить при исполнении, и это работа тикета: какая версия провайдера
`apache-airflow-providers-docker` встаёт к 3.3.0; что имя сети стенда
(`${COMPOSE_PROJECT_NAME}_default`) достаётся дагу без ручной подстановки; что
триггер доходит и дочерний прогон виден из выключателя; что два запуска подряд
двигают позицию на два дня, а обрыв — ни на один.
+39
View File
@@ -0,0 +1,39 @@
# ADR 0010. Заказы в ODS: версии сущности вместо подмены слепка
Дата: 16 августа 2026 года. Статус: принято. Частично заменяет
[ADR 0008](0008-order-ingestion.md). Колонка версии пересмотрена
[ADR 0014](0014-order-version-snapshot-date.md): вместо `updated_at` —
`snapshot_date`.
## Решение
Пакетный забор заказов остаётся прямым чтением байтового Kafka-чтеца по
команде Airflow, но порция чтения больше не считается полным слепком и не
публикуется заменой партиции `snapshot_date`. Прочитанные строки получают
`_load_id` запуска, разбираются из одного среза STG в годные строки и ошибки,
а `ods.order` хранит принятые версии заказа в
`ReplacingMergeTree(updated_at)`. Ключ сущности — `order_id`, партиция строится
от неизменного `created_at`, все версии ключа направляются на один шард.
`snapshot_date` остаётся датой наблюдения источника, `_load_id` — координатой
запуска приёма, `_load_ts` — временем прибытия строки. Ни одна из них не
заменяет бизнес-версию `updated_at`. Физическая таблица может показывать
несколько версий; точное текущее состояние ODS открывает `ods.order_v`, которое
скрывает `FINAL` или равносильный способ выбора последней версии.
Модель заказа в DDS этим решением не задаётся. DDS получает устойчивую
типизированную поверхность ODS и отдельно решает зерно, связи и способ
материализации своей модели.
## Почему отменена подмена партиции
Прямой `SELECT` Kafka Engine заканчивается после одной полученной порции, а
протокол не несёт признака конца слепка. Поэтому `snapshot_date` не доказывает,
что в STG собрана полная партиция, и её подмена могла бы удалить уже принятые
версии прошлого дня. Маркер конца, опись или фиксация конечных офсетов сделали
бы границу настоящей, но добавили бы новый протокол без нужного стенду урока.
Малый объём позволяет оставить одно чтение на запуск как проверяемое
эксплуатационное допущение, а не границу полноты. Полный контракт разбора,
граница брака и поведение повторов заданы в
[спецификации приёма заказов](../architecture/orders/ingestion.md).
+42
View File
@@ -0,0 +1,42 @@
# ADR 0011. Устройство компонента — связный набор живых документов
Дата: 16 августа 2026 года. Статус: принято.
## Решение
Детальное устройство крупного компонента живёт в `docs/architecture/`
подпапкой: индекс `README.md` — целевая картина и указатели — и отдельный
файл на каждую часть устройства. Имена — слаги без дат; набор правится по
мере постройки, как и остальные справочники этой папки.
Первый такой набор — [заказы бэкенда](../architecture/orders/README.md),
собранный из спеки этапа 3 и спеки приёма. Датированные файлы
`2026-08-16-orders.md` и `2026-08-16-order-ingestion.md` удалены, ссылки на
них перенацелены; историю держит git.
## Почему
**Спека и базовая документация — разные жанры, а файл был один.** Спека —
событие: проект изменения с датой в имени, замерзающий после приёмки. Но
собранная спека заказов сразу стала и детальным устройством сервиса — тем
документом, по которому этап 3 будут строить и с которым потом сверяться.
Живому устройству дата в имени врёт, а замерзать ему нельзя.
**Один большой документ плохо читается обоими читателями.** Агент, строящий
судьбу заказа, вынужден везти в контексте формат провода и приём; человек
листает пятьсот строк ради одного раздела. Индекс с файлами по частям даёт
обоим одно и то же: загружается только нужная часть, а карта целого — один
экран.
**Долговечно только то, что в git.** Резолюции развилок живут в трекере, а
трекер долговечным хранилищем не считается. Поэтому решения — вместе с
отклонёнными вариантами при каждом правиле — лежат в файлах набора; ссылки
на тикеты остаются вежливостью, не записью.
## Следствия
- `docs/specs/` остаётся событиям: мастер-спека и спека генератора живут как
есть; переводить ли их в живую форму — отдельное решение, когда встанет.
- Раздел «Структура» в AGENTS.md обновлён тем же коммитом.
- Ссылки из ADR 0008 и 0010, мастер-спеки, спеки генератора, исследования
формата и доки хранилища перенацелены на набор.
+219
View File
@@ -0,0 +1,219 @@
# ADR 0012. Справочники: зона вне цепочки слоёв и подложка под словарём
Дата: 20 августа 2026 года. Статус: принято.
## Решение
**Дом.** Справочные данные живут в базе `dic`, вне цепочки STG → ODS → DDS → DM.
Каталог товаров переезжает из `dds.products` в `dic.products`. Правило одно и
оно не про слой: справочник — самостоятельная зона; читать её вправе любой
слой, писать — никто, кроме владельца источника. Будущий словарь регионов
попадает туда же, независимо от того, какой слой его читает.
**Устройство.** Словарь читает не файл, а таблицу хранилища. Под `dic.products`
лежит подложка `dic.products_file` на движке `File` — она ничего не хранит и
перечитывает CSV на каждом запросе. Словарь берёт её источником `CLICKHOUSE` от
собственного пользователя с одним правом на чтение и обновляется сам, окном
`LIFETIME(MIN 60 MAX 90)`.
Источник объявлен формой `query`, а не `table`: словарь приводит цену каталога
из целых копеек к `Decimal(18, 2)` — то есть нормализует на входе, а не
зеркалит подложку.
Форма чтения принадлежит потребителю, а не месту хранения: подстановка атрибута
по ключу идёт через `dictGet`, а соединение и фильтрация по атрибуту — по
подложке напрямую.
## Почему зона, а не прописка в слое
**Справочник читают несколько слоёв, а не производит ни один.** Каталог нужен
модели заказов в DDS и витрине выручки в DM; будущие регионы — представлению
событий в DDS; `analyst` читает его напрямую. Писателя нет вовсе: содержимое
приходит из файла репозитория. Прописка внутри `dds` приписывала слою модели
владение, которого у него нет: DDS этот объект не производит, не обновляет и не
проверяет.
**Промах был во владении, а не в направлении.** Чтение из DM объекта, лежащего
в DDS, — течение вниз, ровно то, которое разрешено; на инверсию слоёв это
решение не опирается. Зато зона закрывает будущий случай: как только справочник
понадобится выше по течению — скажем, проверка `sku` при разборе заказа в ODS —
чтение `dds.*` из `ods.*` стало бы настоящей инверсией. У зоны вне цепочки
направления относительно слоёв нет, и такой запрос законен по построению.
**Отраслевой образец говорит то же самое другим средством.** Владелец принёс
устройство промышленного хранилища телекома — источник закрытый, проверить по
ссылке нельзя, поэтому вес у довода такой же, как у полевого опыта, а не как у
документации. Там база одна, слои выражены префиксами имён, и справочники несут
собственный префикс наравне с префиксами стадий — то есть образуют свою зону, а
не приписаны к слою модели. Причём префикс накрывает всю цепочку справочника: и
таблицу, и представление над ней, и сам объект словаря. Средство разное,
утверждение одно. У нас слои выражены базами — значит справочникам полагается
база.
**Имя `dic`, а не `ref`.** `ref` точнее называет содержимое, `dic` —
узнаваемее: это то слово, которое менти встретит в промышленном хранилище. Три
буквы становятся в ряд к `stg`, `ods`, `dds`, `dm`. Опасение, что `dic` называет
механизм и содержимое его перерастёт, снято проверкой: словарь и так читается
как таблица, а зона держит и обычные таблицы тоже.
## Почему подложка, а не файл прямо в словаре
Это намеренное усложнение, и платит за него учебная ценность. Урок называется
одной фразой: **словарь читает таблицу хранилища, а не файл рядом с сервером, —
потому что в бою справочник приезжает процессом.** Файловый источник у словаря
работает и на стенде работал, но в бою он редкость: там источник приезжает ETL
или словарь смотрит прямо в чужую базу. Стенд учит форме, которую менти
встретит.
Подложка добавляет к уроку три вещи, которых у файлового источника нет:
предложение `SOURCE` с запросом и учётной записью; обновление окном вместо
ручной команды; и справочник, доступный соединению — а не только `dictGet`.
**Цена приводится к `Decimal(18, 2)` в самом источнике.** В файле лежат целые
копейки, и `129000` о своей единице не говорит ничего — а каталог единственное
место на стенде, где число правит рукой человек. Довод не в единообразии:
стенд намеренно держит три представления денег, и расхождение между ними
заявлено уроком. Довод в том, что урок про округление живёт ровно на стыке
каталога и события, где `productPrice` — уже целые рубли. С `Decimal` менти
сравнивает `1290.00` против `1290` и видит потерю; с `Int64` он сначала делит
на сто в уме, в тот самый момент, когда должен смотреть на округление.
Приведение вынуждает форму `query` у источника — подложка сырая и
конвертировать не умеет, а `Decimal` прямо на ней прочитал бы `129000.00`.
**Историзацию и представление «последняя загрузка» из образца не берём.** В
промышленном примере таблица копит загрузки с меткой времени, а представление
берёт последнюю: источник приезжал извне, и версий у него не было нигде. У нас
версии есть — **история каталога это и есть git**. Дублировать её в ClickHouse
значит поставить конструкцию, у которой на стенде нет причины, а расшифровывать её
менти всё равно придётся.
**Материализованной таблицы с шагом наполнения тоже не берём.** Шаг наполнения
— вторая половина промышленного устройства, и она стоит движущейся части:
кто-то обязан его запускать. Расписание для файла в git врало бы: каталог не
меняется сам. Подложка на файловом движке даёт `SOURCE` без этой цены.
## Отвергнутые варианты размещения
- **Оставить в `dds`** — разделяемый ресурс внутри одного из потребителей; учит
тому, что в бою устроено иначе. Совпадение с будущими регионами держится, но
по другой причине, чем у товаров, — значит это не правило.
- **Рядом с главным потребителем** — потребителей несколько и они в разных
слоях: товары читает витрина, регионы — представление DDS. Правило требует
выбрать главного там, где главного нет.
- **Рядом с владельцем содержимого или предметной областью** — владелец у обоих
справочников один и тот же, репозиторий стенда; правило ничего не различает.
Предметные базы вдобавок разошлись бы со слоёвыми.
- **Без единого правила, по происхождению каждого справочника** — оставляет
менти список случаев вместо урока.
- **Выразить принадлежность именем объекта, а не базой** (снять исключение
[ADR 0006](0006-object-naming.md), дать словарю суффикс вида) — чинит
читаемость имени, но не трогает ни владение, ни зону. При базе `dic`
избыточно: адрес говорит это раньше имени.
- **Не выражать принадлежность в хранилище вовсе** — отказ от предмета решения.
Вдобавок `SHOW DICTIONARIES` без указания базы возвращает пусто, так что поиск
объекта переложился бы на `system.dictionaries`.
- **Отказаться от объекта-словаря, читать CSV табличной функцией** — убирает
разговор о политике обновления, ради которого раздел 3 мастер-спеки словарь и
держит.
- **Отложить решение до второго справочника** — выглядит осторожным, но им не
является. Регионы стоят в спеке опорной точкой для будущих лекций, и CSV под
них не существует. DM же придёт этапом 4 и впишет старый адрес в витрины.
Повод пересмотреть не наступает, а цена только растёт.
## Условия пересмотра
- **Дом.** Появляется справочные данные, которые хранилище производит само —
скажем, таблица соответствий, собранная из DDS. Посылка решения «в хранилище
их никто не производит» на них не распространяется, и зону придётся либо
сузить до пришедших извне, либо переопределить. Дешевле всего это до этапа 4:
как только витрины начнут звать словарь по имени, каждый разворот станет
правкой по всем ссылкам.
- Справочник перестаёт быть файлом репозитория и начинает приезжать извне —
возвращается вопрос устройства: историзованная таблица, «последняя загрузка»,
словарь поверх. Адрес это переживает.
- Усложнение перестаёт окупаться: если менти проходит мимо подложки не заметив
её, режется обратно до файлового источника у словаря.
## Следствия
- [ADR 0007](0007-clickhouse-access.md) правится тем же коммитом: у зоны свой
блок прав и свой пользователь.
- [ADR 0006](0006-object-naming.md) получает шестой суффикс вида — `_file`,
чтец файла, ровно параллельный `_kafka`. Исключение для словарей остаётся и
читается яснее прежнего: голое имя означает словарь.
- **Обновление словаря на кластере не атомарно, и это заявленное свойство.**
Случайный момент внутри окна разводит опросы разных серверов, чтобы они не
ходили к источнику разом. Побочный эффект — ноды перезагружают словарь в
разное время. Ждать правки каталога каждой из них приходится от нуля до
верхней границы окна, а фазы у них независимы, поэтому расходиться они могут
почти на все полторы минуты: одна перезагрузилась сразу после правки, вторая
ещё нет. Прежнее решение `LIFETIME(0)` окно не закрывало, а сжимало до
разброса исполнения `SYSTEM RELOAD DICTIONARY ON CLUSTER`; теперь оно
раскрыто намеренно. Урок записан опорной точкой в мастер-спеке, тащить его
в курс — отдельное решение.
- **Номенклатура товаров — медленно изменяющийся справочник**, и в предметной
области это так. Стенд упрощает: файл в репозитории под версией git.
Историзации в хранилище нет, и обещать её этот документ не должен.
- Витрина выручки спроектирована представлением, поэтому категория будет
подставляться в момент запроса, и правка каталога изменит отчёт задним
числом. Материализуй мы категорию при приёме — не изменила бы. Самой витрины
ещё нет, она приходит этапом 4.
## Что проверено
Замерено на живом стенде 20 августа 2026 года, ClickHouse 26.3.17.56.
- Движок `File` с явным путём внутрь `user_files` работает и раскатывается
`ON CLUSTER`: обе ноды читают свой смонтированный файл, по 180 строк.
- Относительный путь у этого движка считается **от `user_files`**, а не от
корня данных: форма `./user_files/catalog/products.csv`, рабочая для
файлового источника словаря, даёт `Code: 107 ... FILE_DOESNT_EXIST`.
- Подложка ничего не хранит: строка, дописанная в файл снаружи, меняет
`count()` без единой команды перезагрузки.
- Словарь подхватывает правку сам. `element_count` изменился без
`SYSTEM RELOAD DICTIONARY` через 116 секунд после предыдущей загрузки —
внутри окна `MIN 60 MAX 120`, на котором шёл замер.
- Без указания пользователя источник `CLICKHOUSE` ходит как `default` с пустым
паролем и падает: `Code: 516 ... AUTHENTICATION_FAILED`. Пользователя надо
называть явно.
- Приведение цены: `toDecimal64(price, 2) / 100` даёт ровно `Decimal(18, 2)`,
`129000 → 1290.00`, `128990 → 1289.90`. Вариант через `divideDecimal`
возвращает `Decimal(76, 2)` — шире, чем нужно, и не взят.
- Беспарольный пользователь, объявленный файлом настройки, принимается молча:
словарь грузится, `dictGet` отвечает, в журнале после перечитывания конфига
нет ни одного предупреждения про `no_password`.
- `SHOW DICTIONARIES` без указания базы возвращает пусто; словарь в базе стоит
среди таблиц слоя и отличается от них только колонкой движка в
`system.tables`.
- До переноса `dds.products` стоял в семи строках трёх файлов: дока хранилища,
DDL словаря и проверка `scripts/check-clickhouse.sh`. Первый замер насчитал
четыре и проверку не увидел — искали с фильтром по расширениям, куда `.sh`
не попал. Из недосчёта вырос настоящий дефект: девятая проверка кластера
осталась звать несуществующий объект и упала. Поймало ревью, не автор.
Урок тот же, что в [ADR 0006](0006-object-naming.md): «прогнал по
репозиторию» — такое же утверждение, как утверждение о поведении системы, и
проверять его надо так же.
- Движок `File` требует у `etl` права на источник. Отказ называет
`TABLE ENGINE ON File`, но права с таким именем не хватает: замер тремя
пользователями показал, что работает `GRANT FILE ON *.*`, а `TABLE ENGINE ON
File` не нужен вовсе.
- Стенд, собранный с нуля этим решением, поднимается зелёным: оба объекта зоны
на месте, словарь `LOADED` со 180 строками, база `dds` пуста, `bi` читает
словарь со второй ноды, `analyst` — подложку соединением. Словарь спрашивает
`make check-clickhouse` — 10 проверок из 10; девятая берёт известный `sku` на
обеих нодах и сверяет цену с колонкой файла, умножив её обратно на сто, так
что приведение к `Decimal` проверено вместе с ответом. `make smoke` словарь
не трогает вовсе.
Сверено по документации ClickHouse через MCP Context7 20 августа 2026 года.
- Движок таблиц `Dictionary` существует затем, чтобы выставить словарь явной
таблицей — когда нужен доступ к сырым данным или соединение.
- Для небольших измерений документация рекомендует словарь вместо `JOIN`:
соединение выполняется до фильтрации `WHERE` и между запросами не кэшируется.
- Обратный случай назван там же: если фильтровать по подставленному значению на
многих строках, лучше обычная колонка с индексом — `dictGet` считается
построчно и индексом не поддержан. Отсюда обе формы чтения в решении.
- Диапазон `LIFETIME(MIN … MAX …)` перезагружает словарь в случайный момент
внутри окна, чтобы разнести обращения разных серверов к источнику.
- Если хост источника локальный, запрос идёт без сети.
+70
View File
@@ -0,0 +1,70 @@
# ADR 0013. Жизненный цикл мира ведёт Airflow
Дата: 21 августа 2026 года. Статус: принято. Реализация — тикет #119.
## Решение
Compose поднимает службы стенда. Airflow владеет прикладным миром: топиками
Kafka, DDL прикладных баз ClickHouse, стартовыми данными и позицией модельного
времени. `make` поднимает инфраструктуру и через REST API запускает нужный даг,
дожидаясь его результата.
Безопасный даг `world_initialize` создаёт мир, если тот ещё не создан. На
готовом мире он ничего не меняет и успешно сообщает об этом в журнале.
Разрушительный даг `world_recreate` очищает прикладные базы, топики и позиции
их потребителей, затем запускает `world_initialize` и ждёт его завершения.
Канонический DDL применяет отдельная учётная запись Airflow с правами только на
прикладную структуру стенда.
`world_position` остаётся единственным состоянием пульта:
- переменной нет — стенд ещё не инициализировали;
- `-1` — прежний мир признан недействительным, очистка не завершена;
- `0` — мир очищен и готов к начальной генерации;
- `8` и больше — первый несыгранный день готового мира.
`world_recreate` ставит `-1` до первого удаления и `0` только после успешной
очистки. `world_initialize` принимает отсутствие позиции или `0`, а значение
`8` и больше считает готовым миром. После генерации стартовых дней и успешного
приема обоих источников позиция становится `8`; затем инициализация последним
шагом ждет дневной конвейер `etl_pipeline`. При отказе преобразований мир
остается готовым на позиции `8`, а конвейер после исправления запускают рукой.
Обычные даги движения мира работают только с готовой позицией.
## Почему
Прежняя цепочка делила один процесс между Compose, shell-скриптами и Airflow:
одноразовые службы создавали стартовые данные, отдельный сценарий ждал их
приёма, а пересборка сама исправляла внутреннюю переменную пульта. Из-за этой
границы успешная отправка в Kafka выдавалась за создание мира, повторный
`make up` смешивался с инициализацией данных, а узкая пересборка сохраняла в
Kafka сообщения и офсеты прежнего мира.
Airflow уже ведёт последующие дни и приём заказов. Отдав ему начало той же оси,
получаем один наблюдаемый процесс и одну точку успеха: зеленая ветка создания в
`world_initialize` подтверждает, что схема создана, стартовый мир принят, его
следующий день известен, а слои DDS и DM собраны. Короткая ветка готового мира
по-прежнему подтверждает позицию и ничего не меняет.
## Что это решение уточняет
- В [ADR 0007](0007-clickhouse-access.md) DDL больше не принадлежит `etl`:
прикладную структуру ведёт отдельный `lifecycle`.
- В [ADR 0009](0009-world-control.md) отсутствие `world_position` больше не
означает готовый стартовый мир. Начальную позицию ставит
`world_initialize`, а работники принимают только `8` и больше.
- Одноразовые службы Compose и shell-ожидание, описанные прежним устройством
хранилища, отменены. Их последовательность стала наблюдаемым графом Airflow.
## Отклонено
- Оставить создание мира в Compose, а позицию исправлять из
`make rebuild-storage`: работает технически, но сохраняет двух
оркестраторов одного процесса.
- Один безусловно разрушительный даг для первого и повторного запуска: обычный
`make up` мог бы стереть уже прожитые дни.
- Отдельная переменная состояния мира: она дублировала бы
`world_position` и могла разойтись с ней. Значения `-1` и `0` выражают
нужные переходы в одном месте.
- Выдать обычной учётной записи `etl` право удаления баз: рабочий конвейер не
должен получать административные права из-за редкой операции восстановления.
@@ -0,0 +1,96 @@
# ADR 0014. Версия заказа в ODS: дата слепка
Дата: 26 августа 2026 года. Статус: принято. Пересматривает колонку версии
из [ADR 0010](0010-order-versions-in-ods.md); остальные решения того ADR в
силе. Развилка разобрана в #163, реализация — #165.
## Решение
`ods.order` хранит версии заказа в `ReplacingMergeTree(snapshot_date)`: при
схлопывании выживает строка позднейшего слепка, который нёс заказ. Формула
решения: у полного слепка версия — сам слепок; истина — то, что источник
сказал последним.
Ключ сущности, партиция и шардирование не меняются. `updated_at` остаётся
деловым полем, и деловой ответ слоя от смены версии не зависит: на
канонических слепках выбор по дате слепка побайтово совпадает с выбором по
деловой метке, потому что метка монотонна по слепкам, а слепок дня — чистая
функция зерна и дня (тесты генератора
`test_the_state_is_read_at_the_boundary_of_the_day` и
`test_two_runs_give_the_same_snapshot`).
Решение покупает две вещи.
- **Детерминизм слоя.** Разные слепки несут разные даты — ничьей между
содержательно разными строками не бывает. При версии `updated_at` доставки
неизменившегося заказа равны по версии, и выжившая строка — жребий: у 86
заказов стартового мира выживала строка серединного слепка, притом что у
всех 3034 пар строк с равным `updated_at` деловые поля совпадали побайтово
(замеры #137). Документация ClickHouse обещает при ничьей победу последней
вставленной строки — замер это опроверг, полагаться на неё было бы ошибкой.
- **Правдивый ярлык.** `snapshot_date` выжившей строки — последний слепок,
который нёс заказ. Объём пересборки DDS читается прямо из данных, причём
без `FINAL`: слияние сохраняет максимум даты по построению. Константа
окна K уходит со стороны стенда вместе с риском дрейфа её двух копий
(ревью #151 ставило этому риску MAJOR).
## Ответ доводу ADR 0010
ADR 0010 отверг семейство «публикацию решает дата наблюдения»: у потребителя
нет признака полноты слепка. Довод целил в подмену партиции — ей полнота
нужна, потому что неполная партиция стёрла бы уже принятые версии. Колонке
версии полнота не нужна: она сравнивает свежесть строк одного заказа, и
недоехавшая строка просто оставляет в силе предыдущую — ровно как при версии
по деловой метке.
## Границы допущения
- **Дисциплина слепков.** Слепок с поздней датой не несёт состояния старше.
Источник, собирающий слепок из отставшей реплики, это ломает; модельный
мир — нет: см. тесты выше.
- **Одна дата — один слепок.** Ограничение вшито в движок: вторая
внутридневная выгрузка дала бы ничью уже на деловых полях. Это условие
контракта источника, его место — в границе строгого приёма
([приём заказов](../architecture/orders/ingestion.md)).
- **Окно наследует потерю строк.** Частичный отказ вставки после коммита
офсетов может унести старший день последнего слепка — выведенное окно
молча сузится. Лекарство прежнее, сверка слоя с источником в контуре DQ
([конвейер](../architecture/etl.md), «Чего конвейер о себе не знает»).
- **Тихая правка лечится только внутри окна.** Строку, изменённую без
сдвига `updated_at`, перекроет следующий слепок; замёрзший заказ источник
больше не показывает, там лечит лишь сверка полей.
- **Повторная доставка слепка.** Байты совпадают, жребий остаётся только на
`_load_id` и `_load_ts`; ярлык даты детерминирован.
- **Правка живого мира не подхватится.** DDL исполняется при рождении мира,
`ALTER` колонку версии не меняет: решение вступает в силу пересозданием
хранилища (`make rebuild-storage`).
## Отклонённые варианты
- **Оставить как есть, сверку копий K отдать DQ.** Лотерея ярлыков остаётся
задокументированным грехом, окно — константой с риском дрейфа.
- **Убрать координаты доставки из ODS.** Наказывает поле за вопрос, на
который оно не бралось отвечать: расследованию (связка годных строк,
ошибок и сырья) координаты служили честно.
- **Дедупликация на вставке.** Детерминизм «обычно», не по построению:
анти-джойн с собственной целью не атомарен, лаг реплик протаскивает дубли.
Приём теряет урок «разобрал и вставил», а ярлык со смыслом «первый
носитель» хуже для обеих ролей поля: окну нужен последний носитель, и
сырьё живо скорее у него.
- **Склейка `(updated_at, snapshot_date)` в одну колонку версии.** Движок
берёт одну колонку, склейка требует битовой упаковки. Робастнее к
недисциплинированному источнику, но платит читаемостью DDL за защиту от
сценариев, которых модельный мир не производит.
- **Склейка `(snapshot_date, updated_at)`.** Путь отступления: если у
источника появятся переиздания слепка одной даты, версия дорастает до
этой формы. От правок без сдвига метки внутри одной даты не спасает и она.
## Связанные решения
- [ADR 0010](0010-order-versions-in-ods.md) — версии заказа в ODS; здесь
пересмотрена только колонка версии.
- [Приём заказов](../architecture/orders/ingestion.md) — граница строгого
приёма и четыре координаты строки.
- [Модель заказа в DDS](../architecture/dds/order.md) — читатель окна
пересборки.
- Развилка, веер и два холодных ревью — #163.
+36
View File
@@ -0,0 +1,36 @@
# ADR 0015. Модельный день считается в часовом поясе счетчика
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
## Решение
Модельные сутки считаются в часовом поясе счетчика UTC+4. `EventDate` обозначает
дату в этом поясе, а `UTCEventTime` — абсолютную метку времени. Граница
модельного дня приходится на полночь счетчика.
Суточная волна посетителя привязана к его местному времени. У гостя из другого
пояса она сдвигается на разницу с поясом счетчика, но общая граница дня остается
прежней.
## Почему
Так устроена выгрузка Метрики: дата события уже посчитана в поясе счетчика.
Полночь в этом поясе не разрезает утренние сессии в регионе мира. Регион можно
выбирать независимо от технического пояса счетчика.
Для всего потока дня нужна одна граница суток. Местное время гостя описывает
поведение в пределах этих суток, а отдельная дата для каждого посетителя не
нужна.
## Отклонено
В разделе 9 отклоненные варианты не зафиксированы.
## Следствия
У ночных событий `toDate(UTCEventTime) != EventDate`. Хранилище берет день из
готового `EventDate`, а не выводит его из абсолютной метки. Поворот суточной
волны слегка размывает общий пик активности гостей из разных поясов.
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
раздел 9.
+28
View File
@@ -0,0 +1,28 @@
# ADR 0016. Паспорт куки хранится в плане состава на всю ее жизнь
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
## Решение
Браузер, операционная система, устройство, экран и город принадлежат куке на всю
ее жизнь. План состава выбирает и хранит паспорт, а день только переносит его в
поля событий.
Две куки одного человека получают один город и разные устройства. Броски
паспорта идут после прежних бросков состава, поэтому прежние измерения не
сдвинулись.
## Почему
Кука представляет браузер на устройстве, поэтому ее паспорт не должен меняться
между днями. Только план состава связывает две куки с одним человеком. Поэтому
он может задать им один город и разные устройства.
## Отклонено
- Выводить паспорт арифметикой из `ClientID`: этот идентификатор не несет
сведений о двухкуковой паре, поэтому по нему нельзя задать двум кукам одного
человека один город и разные устройства.
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
раздел 9.
+34
View File
@@ -0,0 +1,34 @@
# ADR 0017. Номер заказа объединяет две проекции покупки
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #40.
## Решение
Номер заказа включает день по модельному времени и порядковый номер покупки в
этот день, например `20260603-0042`. Событийная сторона пишет его в
`purchaseID`, бэкенд — в `order_id`; обе стороны берут один номер из плана
покупки.
Все покупки, попавшие в поток дня, нумеруются в порядке событий и до любых
потерь следующих этапов.
## Почему
По читаемому номеру сразу виден день заказа. Один номер соединяет две проекции
одной покупки. Генераторы кликстрима и бэкенда не согласуют его отдельно.
Присвоение до потерь сохраняет номера остальных заказов: исчезновение одного
события не должно перенумеровывать чужие покупки и разрывать связь с бэкендом.
## Отклонено
- Присваивать номера после внесения потерь: одна потеря сдвинула бы все
следующие номера за день и разорвала бы связь уже созданных проекций.
## Следствия
`purchaseID` и `order_id` изначально совпадают. Из-за расхождения событие может
исчезнуть после присвоения номера, но номера сохранившихся заказов не меняются.
Решение принято при исполнении тикета #40 и зафиксировано в спеке этапа 2,
раздел 9.
+55 -1
View File
@@ -46,6 +46,51 @@
уже с сохранённым токеном, например
`tea api repos/ddmitry/clickstream-data-platform/issues/18`.
## Правка тела issue — только через API
Тикнуть чекбокс в критериях приёмки нужно при закрытии каждой задачи, и `tea`
для этого не годится. `tea issues edit --description` принимает тело **целиком
строкой**, а взять эту строку неоткуда: `tea issues <номер>` печатает текст
обёрнутым и разрисованным для терминала, обратно его не скормишь.
Рабочий путь — забрать сырое тело из API, поправить и вернуть:
```sh
# 1. сырой JSON тикета; тело — в поле body
tea api repos/{owner}/{repo}/issues/<n> > issue.json
# 2. правка тела любым удобным способом, например все чекбоксы разом
python3 -c "
import json
d = json.load(open('issue.json'))
json.dump({'body': d['body'].replace('- [ ]', '- [x]')},
open('patch.json', 'w'), ensure_ascii=False)
"
# 3. вернуть
tea api -X PATCH -d @patch.json repos/{owner}/{repo}/issues/<n>
```
Флаги `tea api`: `-X` — метод, `-d` — сырое тело JSON (`@файл` читает из файла,
`@-` из потока ввода), `-f` и `-F` — отдельные поля строкой и с типом. Флага
`--input` нет, хотя рука тянется написать именно его.
Тот же приём — для чек-листа родительской карты: пункт `- [ ] #NN` тикается
правкой её тела, автоматически Gitea этого не делает.
Комментарий правится тем же способом, но путь у него свой — без номера issue:
`tea api -X PATCH -d @patch.json repos/{owner}/{repo}/issues/comments/<id>`.
Идентификатор берётся из `GET .../issues/<n>/comments`; это не порядковый номер
комментария в ленте. Нужно это чаще, чем кажется: резолюции wayfinder-тикетов
живут комментариями, и правка после ревью идёт в них.
**Отсюда общее правило.** `tea` удобен там, где команда создаёт объект или
меняет его свойство: создать issue, добавить комментарий, повесить метку,
назначить исполнителя, закрыть. Как только нужно **изменить уже написанный
текст** — тело issue, тело PR, — CLI мешает: он умеет только заменить всё
целиком, а прочитать это «всё» в пригодном для правки виде не даёт. Такие
операции идут прямо в API.
## Слияние PR не закрывает issue
Gitea понимает только английские ключевые слова автозакрытия: `closes`,
@@ -59,7 +104,8 @@ PR #17, где issue #13 пришлось закрывать руками.
Если ключевого слова не было, порядок после слияния: закрыть задачу
(`tea issues close <номер>`) и тикнуть её пункт в чек-листе родительского
issue этапа — Gitea чек-листы сама не обновляет.
issue этапа — Gitea чек-листы сама не обновляет. Чем тикать — выше, в разделе
про правку тела.
## Спека — источник истины
@@ -119,6 +165,14 @@ issue этапа — Gitea чек-листы сама не обновляет.
## Что проверено и когда
2026-08-13: правка комментария через `repos/{owner}/{repo}/issues/comments/<id>`
снята живыми запросами при закрытии #72 — так дважды правилась резолюция тикета.
2026-08-06: правка тела issue через `tea api -X PATCH -d @файл` снята живыми
запросами при закрытии #37 — так проставлены чекбоксы самого тикета и пункт
в чек-листе карты #4. Тогда же проверено, что `tea issues edit --description`
для этого непригоден, а флага `--input` у `tea api` нет.
2026-07-31: Gitea 1.27.0 (`tea api version`), `tea` 0.15.0. Живыми запросами по
этому репозиторию проверены `tea labels list` и `tea issues list`. Остальной
набор команд, флагов и приёмы с `tea api` перенесены из доки
+59
View File
@@ -0,0 +1,59 @@
# Слой DDS: бизнес-модель хранилища
Устройство детального слоя: сущности, которыми хранилище говорит с
витринами, их связи и правила слоя. Набор собирается развилками карты #127
(этап 4) и правится по мере постройки.
Рамки заданы мастер-спекой [«Боевой реализм стенда
(v2)»](../../specs/2026-07-30-stand-v2-realism.md): карта таблиц и витрины —
раздел 7, сверка — раздел 4, склейка идентичностей — раздел 5. Дом выбран
развилкой #130 — первой, которой понадобилось записать резолюцию в доки:
записи этапа 4 живут здесь, по хранилищную сторону границы
«источник | хранилище». Устройство источников остаётся их
собственным наборам ([заказы бэкенда](../orders/README.md)) — генератор
переносим и о нашем DDS не знает.
## Карта модели
Целостность модели — связи и ко-локация — живёт в этом разделе; файлы
сущностей держат глубину и на карту ссылаются.
- **Заказ** (`dds.order_v`, хранение `dds.order_rep` / `_dist`) — физическая
сущность: одна строка — заказ в текущем состоянии, позиции массивами в
строке, наполняется заменой партиций — [модель заказа](order.md).
- **Сессия** (`dds.session_v`, хранение `dds.session_rep` / `_dist`) —
расчётная сущность: одна строка — визит,
нарезку по правилу визита стенд делает сам, `VisitID` генератора — эталон
сверки; наполнение заменой дневной партиции — [модель сессии](session.md).
- **Событие** (`dds.event_v`) — контракт над ODS, а не копия: представление,
переводящее событие на язык бизнес-модели, — [контракт события](event.md).
- **Карта идентичностей** (`dds.identity_map_v`, хранение
`dds.identity_map_rep` / `_dist`) — кука ↔ пользователь магазина —
[модель карты](identity.md).
Связи держатся двумя ключами моста (спека, разделы 4 и 5): `purchaseID[1]`
события равен `order_id` заказа — по нему идут сверка и сборка склейки;
`user_id` заказа связывает куки одного человека. Ко-локацию задаёт
шардирование: события и карта идентичностей раскладываются одним выражением
от куки, заказы — от `order_id`. Поэтому обогащение витрин склейкой
локально, а сверка и сборка карты ходят через GLOBAL JOIN с обеими
сторонами-подзапросами ([исследование
соединений](../../research/2026-08-22-distributed-join.md)).
## Правила слоя
- DDS обслуживает человека с дашбордом: время лежит в поясе счётчика,
пересчёт — один раз при наполнении слоя ([дока хранилища](../storage.md),
«Часовые пояса»).
- Публичный договор каждой сущности — `_v`; роли объектов и конвенция чтения
заданы [докой хранилища](../storage.md), «Имена объектов».
- Служебные колонки — координаты собственной загрузки слоя; чужие ярлыки не
переносятся, а спрашиваются у их слоя по ключу сущности. Вывод правила и
цепочка расследования — [модель заказа](order.md).
- Наполняет слой [дневной конвейер](../etl.md): запуск, объём прогона и
замена партиций на кластере — его устройство.
## Родословная
Дом набора и первая сущность собраны развилкой #130 карты #127; форма
набора — [ADR 0011](../../adr/0011-component-docs.md).
+140
View File
@@ -0,0 +1,140 @@
# Контракт события в DDS
Учебный результат: менти видит первый на стенде пример правила «слой — это
контракт, а не обязательно копия данных». Заказ и сессия — физические
таблицы, а событие в DDS не хранится вовсе: `dds.event_v` — представление,
которое переводит событие на язык бизнес-модели, не копируя ни строки.
Витрины DM читают его, а не ODS напрямую.
## Форма
Обычное представление над `ods.event_v` — вторым уровнем, так что `FINAL`
остаётся спрятанным в один слой, внутри контракта ODS. Единственное
сомнение против такой формы — «представление материализуется до фильтра» —
снято исследованием [проталкивания
условий](../../research/2026-08-22-view-predicate-pushdown.md): условия
внешних запросов доезжают до партиций и первичного ключа сквозь два уровня
представлений и переименование колонок.
Две оговорки исследования соблюдены контрактом. Ключевая для срезов колонка
`event_date` отдаётся переименованием `EventDate`, не пересчётом — иначе
индекс перестал бы её анализировать. Цена `FINAL` — отключённый
автоматический `PREWHERE` — свойство ODS, представление её не усугубляет.
Как публичный договор DDS, представление перечисляет колонки явно и следует
общей [конвенции чтения](../storage.md), «Имена объектов».
## Язык модели
Имена атрибутов — имена модели, а не нормализованные имена источника
([CONTEXT.md](../../../CONTEXT.md), «Нормализованное имя»). Для большинства
колонок они совпадают: атрибут переезжает под нормализованным именем из
[описания выгрузки](../../formats/clickstream-event.md). Расхождения два,
и каждое — решение модели:
- **`WatchID` → `event_id`**: модель зовёт сущность событием, «watch» —
жаргон выгрузки Метрики.
- **`UTCEventTime` → `event_time` в поясе счётчика** (`Europe/Samara`):
DDS обслуживает человека с дашбордом, время лежит местным ([дока
хранилища](../storage.md), «Часовые пояса»). Пересчёт — смена линзы, байты
те же, проталкиванию не мешает. Второй метки времени контракт не отдаёт:
выбор линзы слой сделал за читателя, UTC остаётся языком ODS.
`ClientID` остаётся `client_id`: это устоявшееся имя куки во всей
экосистеме Метрики, по нему шардирование и мост к `dds.identity_map_v`;
своё имя здесь создало бы третий синоним. `client_timezone` тоже остаётся —
это атрибут посетителя, а не линза времени.
`purchase_revenue` сохраняет смысл поля источника. Тип денег нормализуется в
ODS; точный тип и проверка описаны в разделе
[«Актуальные события»](../storage.md#актуальные-события). DDS только
переименовывает уже нормализованную колонку.
## Расшифровки
Одна: `device_category` — коды 1–4 в строки `desktop` / `mobile` /
`tablet` / `tv`. У настоящей Метрики этот атрибут и есть строка (выгрузка
стенда несёт число — принятое отступление: спека, раздел 1.1), контракт возвращает
ему боевой вид.
Значения `device_category` 1–4 получают имена, остальные сохраняются
десятичной строкой. Новый код остаётся видимым, пока у него не появится имя в
договоре.
Остальные кодированные атрибуты не расшифровываются. Для `goals_reached`
справочника целей на стенде нет, id и есть язык. Регионы в расшифровке не
нуждаются: выгрузка несёт названия рядом с id (`region_city`,
`region_country`), словарь ничего бы не раскрыл. Идея дать менти собрать
справочник регионов самому ушла в лабы — issue #143.
## Что скрыто
- **`VisitID`** — чужой суррогат сессии: свою сессию модель чеканит сама
(`session_id`, [модель сессии](session.md)), а эталон — инструмент
проверки, не атрибут модели. DQ-сверка нарезки идёт за эталоном в
`ods.event_v`: она по построению сопоставляет наш слой с чужим чеканом —
честно, что за чужим она и идёт в его слой.
- **`Sign`** — колонка формата без механики: генератор исправлений не шлёт,
и в модели события колонке ответить нечего. Честный рассказ о ней живёт в
DDL ODS и лекции.
- **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы
`purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри
колонки читает ODS.
- **`_load_ts`** — у представления нет собственной загрузки, а чужие
координаты слой не переносит (правило слоя — [модель
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
ключу события.
Остальные колонки отдаются целиком, включая `ip_address` и оба
`region_*_id`: контракт — не цензор, он прячет только то, чему в модели
нечего сказать.
## Читатели
- Сборка сессий: ключи нарезки (`client_id`, `event_time`,
`event_date`), тип события, страница, UTM, паспорт куки — категория
устройства приходит уже расшифрованной.
- Витрины `dm.events_enriched_v`, `dm.top_pages_daily_v` и
`dm.utm_effectiveness_v` (`declared_revenue` берется из `purchase_revenue`).
- Событийная сторона сверки `dm.purchase_vs_orders_v`: `purchase_id`,
`purchase_revenue`, `event_date`.
- DQ-сверка сессий с эталоном читает не его, а `ods.event_v` —
см. «Что скрыто».
## Не входит
- Форма витрин DM — #135.
- Лаба со справочником регионов — issue #143, проектирование лаб.
## Отклонённые варианты
- **Материализация вместо представления.** Дублировала бы широкий ODS ради
переименований, и урок «слой — контракт, а не копия» пропал бы;
единственный довод за — сомнение в проталкивании условий — снят
исследованием #128.
- **`utc_event_time` второй меткой рядом с `event_time`.** Контракт вернул
бы читателю выбор линзы, который слой обязан делать сам; расхождение двух
часов — урок ODS, не DDS.
- **`visit_id` в контракте.** Сверка и лаба читали бы одну поверхность, но
чужой чекан встал бы атрибутом модели — тем же доводом модель сессии не
пустила его в свою строку. Решение владельца: DQ-опыт показательнее,
когда эталон приходит из другого слоя.
- **`cookie_id` вместо `client_id`.** Честное имя модели, но третий синоним
куки на ровном месте: проза говорит «кука», экосистема — `ClientID`.
- **Словарь регионов на стенде.** Повторил бы урок `dictGet` каталога
вторым разом, а раскрывать нечего — названия уже в строке. Идея «сделать
руками» ушла в лабы (issue #143).
- **Прочие расшифровки** (`goals_reached` в имена целей, `has_gclid` в
Bool). Справочника целей нет; косметика без читателя.
## Связанные решения
- [Проталкивание условий сквозь
представления](../../research/2026-08-22-view-predicate-pushdown.md) —
резолюция #128: форма контракта и обе её оговорки.
- [Модель сессии](session.md) — `session_id` и довод против чужого чекана;
сюда же передан вопрос о судьбе `VisitID`.
- [Модель заказа](order.md) — правило служебных колонок слоя.
- Мастер-спека, раздел 7 — два контракта: `ods.event_v` хранит язык
источника, `dds.event_v` говорит языком модели.
- [CONTEXT.md](../../../CONTEXT.md) — «Нормализованное имя», «Хранилище».
+122
View File
@@ -0,0 +1,122 @@
# Карта идентичностей в DDS
Учебный результат: менти строит физический мост между кукой трекера и
пользователем магазина, раскладывает его по ключу будущего соединения и видит,
как пакетный полный пересчёт сочетается с точным публичным представлением.
## Назначение
Карта — адресная книга: «эта кука уже покупала как этот пользователь
магазина». Связь становится известна только после покупки, потому что в
кликстриме есть `client_id`, а `user_id` живёт в заказе. Две куки одного
покупателя дают две строки с одним `user_id`; поэтому витрина может показать
рядом число посетителей по кукам и число известных людей после склейки.
Это не полная клиентская база. Куки без покупки остаются анонимными, а карта не
пытается угадать их владельца.
## Зерно и состав строки
Одна строка — известная пара `client_id` × `user_id`. Деловых колонок две:
- `client_id UInt64` — кука трекера;
- `user_id UInt64` — пользователь магазина.
Собственные служебные колонки `_load_id String` и
`_load_ts DateTime64(3, 'UTC')` показывают, каким запуском и когда пара записана
в DDS. Они имеют читателя при расследовании повтора и потому входят в публичный
договор вместе с деловой парой.
`first_matched_at` нет. Запланированные витрины его не читают, а два возможных
смысла — «первая покупка вообще» и «момент, с которого связь была известна
хранилищу» — требуют разных исторических моделей. Этот вопрос оставлен
[самостоятельной работе](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/145),
а текущий стенд не выдаёт один смысл за другой.
Статус заказа связь не меняет: любой заказ, сопоставленный с событием покупки,
показывает владельца куки. Отмена меняет состояние заказа, но не личность
покупателя.
## Физическая модель и публичный интерфейс
- `dds.identity_map_rep` — локальная
`ReplicatedReplacingMergeTree(_load_ts)`, `ORDER BY (client_id, user_id)`,
без партиционирования;
- `dds.identity_map_dist` — распределённая поверхность записи, шардирование
по `cityHash64(client_id)`;
- `dds.identity_map_v` — публичное обычное представление над `_dist`, которое
перечисляет четыре колонки явно и применяет `FINAL`.
Повторы полного запуска физически могут лежать рядом до фонового слияния.
`FINAL` оставляет одну последнюю версию пары, поэтому пользователь всегда читает
точную карту через `_v`. Роли объектов и доступ следуют общей
[конвенции](../storage.md), «Имена объектов».
Шардирование совпадает с событиями и сессиями. Благодаря этому витрина может
обогащать их картой локально по `client_id`; точную форму распределённого
запроса решает проектирование витрин #135.
## Сборка
Карта собирается один раз в суточной пакетной цепочке после обновления модели
заказов и до зависимых витрин. При догоняющем запуске за несколько дней хватает
одной сборки после всей пачки заказов.
Источники — вся доступная история `dds.event_v` и текущее состояние заказов
`dds.order_v`. Трансформация:
1. оставляет события `purchase` и разворачивает единственный `purchase_id`;
2. соединяет его с `order_id` заказа через `GLOBAL INNER JOIN`;
3. выбирает различные пары `client_id`, `user_id`;
4. добавляет координаты текущего запуска и пишет полный набор в
`dds.identity_map_dist`.
`GLOBAL` нужен потому, что события разложены по `client_id`, а заказы — по
`order_id`: строки соединения не ко-локированы. Правая сторона мала, поэтому
рассылка заказов приемлема. Материализованное представление здесь не подходит:
заказ может появиться в пакетном слепке позже уже принятого события покупки.
## Повтор запуска
Каждый запуск заново получает все известные пары из источников. Он не читает
предыдущую карту и не делает anti-join «вставить только отсутствующее».
`_load_ts` служит версией `ReplacingMergeTree`, а `dds.identity_map_v` прячет
физические повторы через `FINAL`.
Полный пересчёт выбран потому, что карта стенда мала, источники уже содержат
нужную историю, а повтор задачи не зависит от того, на каком месте оборвался
предыдущий. Мир стенда не отзывает связь куки с пользователем, поэтому удалять
исчезнувшие пары не требуется.
## Читатели
- `dm.daily_traffic_v` — посетители по `client_id` и известные пользователи по
`user_id` рядом;
- учебные запросы — показать, как несколько кук одного человека меняют
`uniq`;
- расследование пакетного запуска — `_load_id` и `_load_ts` текущей версии
пары.
## Отклонённые варианты
- **`first_matched_at` в текущей модели.** Нет запланированного читателя и нет
однозначной временной семантики; идея передана в issue #145.
- **Словарь ClickHouse.** Для малой карты мог бы ускорить точечное обогащение,
но спрятал бы физическую модель и не добавил нового урока рядом с уже
существующим словарём товаров.
- **Одно динамическое представление без физической карты.** Повторяло бы
соединение всей истории при каждом чтении, не давало ко-локации результата и
не показывало устройство реального слоя хранения.
- **Инкремент через anti-join с самой картой.** Делал бы результат предыдущего
запуска обязательным входом и усложнял повтор без пользы на объёме стенда.
- **Материализованное представление от событий.** Не увидело бы заказ, который
приехал позже события покупки.
## Связанные решения
- [Карта DDS](README.md) — связи сущностей и публичная поверхность `_v`.
- [Модель заказа](order.md) — текущее состояние заказа и пакетное наполнение.
- [Контракт события](event.md) — поля событий на языке DDS.
- [Исследование распределённых соединений](../../research/2026-08-22-distributed-join.md)
— форма `GLOBAL JOIN` и капкан `INSERT SELECT` между разными ключами
шардирования.
+134
View File
@@ -0,0 +1,134 @@
# Модель заказа в DDS
Учебный результат: менти проектирует бизнес-сущность поверх готового ODS —
выбирает зерно, состав строки и способ наполнения — и видит идемпотентность
трансформации, устроенную иначе, чем в приёме: заменой партиции, а не
версиями.
## Зерно
Одна строка — заказ в текущем состоянии, ключ — `order_id`. Модель отвечает
на вопрос «что сейчас известно о заказе»; историю версий не хранит:
читателя у истории нет ни в одной витрине, а ODS её и не обещает —
промежуточные версии съедают фоновые слияния. «Дыхание» выручки внутри окна
K живёт на текущем состоянии само: пересборка дня меняет статусы заказов, и
число дня меняется вместе с ними.
## Состав строки
Деловые поля источника переезжают из `ods.order_v` без переименований —
бэкенд уже говорит в snake_case: `order_id`, `user_id`, `status`, деньги в
`Decimal` (`items_total`, `discount`, `delivery`, `total`).
Времена `created_at` и `updated_at` пересчитываются при загрузке в пояс
счётчика: DDS обслуживает человека с дашбордом ([дока
хранилища](../storage.md), «Часовые пояса»). Там же один раз считается
**день заказа** — колонка `order_date`, по образцу `EventDate` у события:
суточные срезы группируются по готовой дате и пояса не упоминают.
Позиции разобраны из сырой строки `items` в три типизированных массива
одной длины — артикул, количество, цена (`Decimal`). Это родная для
ClickHouse денормализация: позиция не живёт без заказа и собственного ключа
не имеет, а разворачивает массивы читатель — `ARRAY JOIN` в витрине
выручки, с обогащением категорией через `dictGet`
([мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 7). Разбор
JSON происходит один раз, на границе ODS → DDS, куда его и положила спека
(раздел 2); записывать его параллельными колонками-массивами или `Nested` —
выбор DDL, не модели.
## Движок и наполнение
`dds.order_rep` — `ReplicatedMergeTree` без колонки версии: партиция —
`order_date` (день заказа неизменен), ключ сортировки — `order_id`.
`dds.order_dist` шардирует по `cityHash64(order_id)`, тем же выражением, что
ODS: это единственный устойчивый ключ сущности.
Идемпотентность держит замена партиции, а не версии. Сбор дня — батч после
прихода слепка: трансформация собирает партицию заново из `ods.order_v` и
атомарно подменяет. Поэтому каждая строка физической таблицы — ровно один
заказ, а чтению не нужен `FINAL`. Пока окно K живо, день «дышит» — статусы
заказов меняются, и партиция дня пересобирается очередным запуском; на выходе
из окна день замерзает.
Публичная поверхность — `dds.order_v`: обычное представление с явным списком
деловых и собственных служебных колонок над `dds.order_dist`, без `FINAL`.
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
объектов».
Механика запуска — где собирается партиция и как подменяется на кластере —
сюда не входит: это устройство [дневного конвейера](../etl.md).
## Служебные колонки
Строка получает `_load_id` и `_load_ts` запуска трансформации, собравшего
её партицию, — по общей конвенции хранилища. При замене партиции они
одинаковы у всех строк дня и отвечают на вопрос «каким запуском и когда
собран этот день».
Ярлыки ODS (`snapshot_date`, `_load_id` приёма, `_load_ts` прибытия) в модель
не переезжают. Они описывают наблюдение и доставку строки в ODS. Модель DDS
хранит текущее деловое состояние заказа. В ODS `snapshot_date` задаёт версию и
называет последний слепок, в котором источник показал заказ
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Дату
наблюдения и координаты доставки читатель спрашивает у ODS по
`order_id`. Правило слоя:
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
спрашиваются у их слоя по ключу сущности**.
Происшествие качества данных расследуется цепочкой, где каждый шаг
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой
запуск собрал день; по `order_id` ODS показывает физические версии и брак —
там свой `_load_id` приёма связывает годные строки, ошибки и сырьё; дальше
сырьё STG, пока живо. Окно расследования задано сроками хранения: сырьё —
трое суток, брак — месяц ([дока хранилища](../storage.md)).
## Читатели
- Витрина выручки `dm.revenue_daily_v`: заказы в `paid`, `ARRAY JOIN`
позиций, категория через `dictGet` (спека, раздел 7).
- Сверка `dm.purchase_vs_orders_v`: `order_id`, `order_date`, `items_total`,
`status`; соединение — GLOBAL JOIN с обеими сторонами-подзапросами
([исследование соединений](../../research/2026-08-22-distributed-join.md)).
- Сборка карты идентичностей: пара `order_id` ↔ `user_id`, источник —
`dds.order_v`.
## Не входит
- Механика запуска трансформации и замены партиций на кластере —
[дневной конвейер](../etl.md).
- Контур проверок качества (даг DQ, `dm.dq_summary`) — отдельный разговор
этапа 4.
- Инкрементальное наполнение вместо пересборки — техдолг #2.
## Отклонённые варианты
- **Одно динамическое представление вместо физической таблицы.** Рамка стенда —
показать модель данных, которую хранилище готовит потребителям; урок
«слой — контракт, а не копия» уже закреплён за `dds.event_v`, а разбор
позиций и пересчёт времён при каждом чтении собирали бы модель заново на
лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу
не противоречит: оно ничего заново не вычисляет.
- **`ReplacingMergeTree` ещё раз.** Повторил бы механику ODS вторым разом:
физически хранил бы устаревшие состояния, вернул бы `FINAL`
и обёртку-представление — а урок версий менти уже отыграл.
- **Отдельная сущность `dds.order_item`.** Вторая таблица, второй вопрос
шардирования и `JOIN` там, где хватает `ARRAY JOIN`; нормализация из
реляционного мира, а не модель под колоночное хранилище. `JOIN` менти
увидит в сверке, работу с массивами — только здесь.
- **Зерно «версия заказа» (история).** Читателя нет; ODS историю не
обещает, а мир при нужде переигрывается генератором.
- **Перенос ярлыков ODS** — см. «Служебные колонки».
- **Без модели вовсе: витрины читают `ods.order_v` напрямую.** Разбор JSON
и пересчёт времён расползлись бы по витринам, а модель данных — то, ради
чего сущность и строится, — не была бы показана.
## Связанные решения
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) — версии заказов в
ODS, устойчивая поверхность `ods.order_v`.
- [Приём заказов](../orders/ingestion.md) — граница строгого приёма и
четыре координаты строки ODS.
- [Проталкивание условий сквозь
представления](../../research/2026-08-22-view-predicate-pushdown.md) —
резолюция #128 той же карты: цена `FINAL` и цепочек представлений, фон, на
котором здесь выбрана физическая таблица.
+162
View File
@@ -0,0 +1,162 @@
# Модель сессии в DDS
Учебный результат: менти строит расчётную сущность, которой нет в потоке, —
режет события на сессии по правилу визита, чеканит идентификатор и сверяет
свою нарезку с эталоном. Стенд здесь играет роль сессионизатора: в бою её
играет Метрика, которая режет визиты у себя и с октября 2025 отдаёт готовый
`VisitID` прямо в хитах. Поэтому стенд считает сессии сам, а `VisitID`
генератора использует только как эталон самопроверки — иначе сборка сессий
выродилась бы в `GROUP BY` по чужому полю и центральный урок
слоя пропал бы. Без этого абзаца действия стенда выглядят странно: «зачем
считать то, что уже дано колонкой».
## Зерно
Одна строка — один визит ([CONTEXT.md](../../../CONTEXT.md)): подряд идущие
события одной куки без пауз длиннее 30 минут, не пересекающие границу суток.
В нарезку входят все три типа событий — торговые садятся на свои страницы
и держат сессию наравне с просмотрами. События внутри сессии
упорядочиваются по метке времени события (`UTCEventTime` — опорная колонка
приёма).
Граница суток проходит по модельному дню счётчика, и он у события уже есть:
`EventDate` — свойство содержимого. Раз сессия сутки не пересекает, у всех
её событий `EventDate` один; он и становится **днём сессии** — колонкой
партиции, без пересчёта.
## Идентификатор
Идентификатор сессии в природе не существует: сессия — расчётная сущность,
и id ей чеканит тот, кто сессионизирует. `VisitID` — такой же суррогат,
отчеканенный генератором в роли Метрики; наш — `session_id`,
детерминированный хеш от пары «кука + начало сессии» (`cityHash64`).
Детерминированность обязательна: партиция дня пересобирается заменой, и
повторный прогон обязан отчеканить те же id — счётчик или UUID это правило
ломают.
`session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка:
в соединениях он не участвует — события его не несут, мост к заказам живёт
на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со
стендовой и назвать расхождение одним числом) и разбор DQ-расхождений.
## Состав строки
Боевой образец — таблица визитов Метрики, парная к нашему формату хита; по
ней состав и собран:
- **ключи и времена**: кука, `session_id`, начало и конец сессии,
длительность, день сессии. Времена — в поясе счётчика по правилу слоя;
- **счётчики**: событий всего, просмотров, корзин, покупок;
- **страницы**: входная и выходная — как `StartURL`/`EndURL` визита; полного
списка страниц нет и у Метрики, топы считаются по событиям;
- **атрибуция**: UTM-метки первого события сессии — так визит атрибутирует
Метрика. В v1 витрина брала последнее касание — заменено осознанно;
- **паспорт куки**: категория устройства (расшифрованная, язык бизнес-модели)
и город — у куки они постоянны, берутся с первого события;
- **служебные**: `_load_id` и `_load_ts` собравшего запуска — правило слоя
([модель заказа](order.md), «Служебные колонки»).
Осознанные расхождения с образцом: **денег в сессии нет** — визит Метрики
несёт клиентскую выручку, у нас деньги считает только бэкенд (спека, раздел
7), а клиентская сумма живёт в сверке; **целей нет** — их место заняли
счётчики торговых событий.
## Движок и наполнение
`dds.session_rep` — `ReplicatedMergeTree` без колонки версии, по образцу
заказа: партиция — день сессии, ключ сортировки — кука и начало сессии.
`dds.session_dist` шардирует по `cityHash64(client_id)`, тем же выражением,
что события, — это ко-локация с `dds.identity_map_rep` ([дока
хранилища](../storage.md), «Раскладка по шардам»).
Совпадение ключа с событиями не случайно: все события куки лежат на одном
шарде, поэтому сессионизация локальна по природе — капкан смены ключа при
вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет.
Сборка — дневной батч: хранилище выше ODS по смыслу батчевое, раз второй
источник приходит раз в сутки. Собирается день, который мир уже прожил, и
собирается один раз и целиком; какой день прожит, слой спрашивает у позиции
мира ([дневной конвейер](../etl.md), «Объём прогона»). Идемпотентность держит
замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих»
дней, у сессии дышащего окна нет: прожитый день самодостаточен (правило
спеки, раздел 6). Второй раз он соберётся только
вынужденно — когда упавшая замена оставила позади дыру и хвост от неё накрыл
соседние дни. Дней в одном прогоне при этом бывает несколько — столько,
сколько мир успел прожить без слоя. Внутри живого дня
модель сессии про сегодня честно молчит — «сегодня» смотрят через
`dds.event_v`; сессия и по смыслу не финальна, пока день не прожит.
Публичная поверхность — `dds.session_v`: обычное представление с явным
списком деловых и собственных служебных колонок над `dds.session_dist`.
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
объектов».
Источник сборки — `dds.event_v` ([контракт события](event.md)): сессии
нужны от него ключи нарезки, тип события, страница, UTM и паспорт куки.
Механика запуска — где собирается партиция и как подменяется на кластере —
не входит: это устройство [дневного конвейера](../etl.md).
## Сверка с эталоном
Счётная DQ-проверка с зерном «кука × день»: число сессий нашей нарезки
равно `uniqExact(VisitID)` по тем же событиям. Счёт по Distributed ловит и
склеенную, и разрезанную сессию — мимо него проходит лишь взаимная
компенсация ошибок внутри одной куки за день; эталон в модель не тащим —
строка сессии остаётся нашей, а не генераторной. Поштучное сопоставление
границ — материал лабы, где менти разбирает найденное расхождение руками. Дом проверки —
контур DQ этапа 4. Сверке нужен `VisitID`, а сборке сессий — нет; контракт
`dds.event_v` его скрывает, и за эталоном проверка идёт в `ods.event_v`
([контракт события](event.md)).
## Читатели
- Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
в ее [ТЗ](../../labs/session-overview.md) (#135).
- DQ-сверка с эталоном (см. выше).
- Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда,
потом сравнивает — с эталоном и со стендовой.
## Не входит
- Механика запуска трансформации и замены партиций на кластере —
[дневной конвейер](../etl.md).
- Контур проверок качества (даг DQ, `dm.dq_summary`) — отдельный разговор
этапа 4.
- Форма витрин над сессиями — #135.
## Отклонённые варианты
- **Готовый `VisitID` вместо своей нарезки.** Сборка выродилась бы в
`GROUP BY` по чужому полю: центральный урок слоя съеден, сверка с эталоном
осталась бы без предмета. Боевой довод «Метрика теперь отдаёт `VisitID` в
хитах» честно рассказывается в лабе — после того как менти собрал сам.
- **Гибрид: сборка по `VisitID`, DQ пересчитывает нарезку.** Сторож на
нарезку, которой сборка не делает; та же работа, что в варианте «считать
сам», но урок спрятан в проверку.
- **Одно динамическое представление вместо физической таблицы.** Оконная
сессионизация на каждое чтение;
урок «слой — контракт, а не копия» уже закреплён за `dds.event_v` — доводы
модели заказа, здесь они только сильнее. Публичное `dds.session_v` поверх
готовой физической модели ничего заново не считает.
- **Счётчик или UUID вместо детерминированного хеша.** Пересборка партиции
выдала бы другие id — замена перестала бы быть идемпотентной.
- **`VisitID` колонкой в строке сессии.** Чужой чекан в своей модели: наша
сессия обязана уметь расходиться с эталоном, на этом стоит сверка;
поштучное сопоставление — лаба, не таблица.
- **Массив посещённых страниц.** Вес без читателя: вход и выход — как у
визита Метрики, топы страниц считает витрина по событиям.
- **UTM последнего касания (как в v1).** Визит атрибутируется своим первым
хитом; последнее касание — атрибуция другого уровня, не сессионного.
- **Деньги в сессии.** Выручка — только по бэкенду; клиентская сумма живёт
в сверке (спека, разделы 4 и 7).
## Связанные решения
- [Модель заказа](order.md) — прецедент замены партиций в DDS и правило
служебных колонок слоя.
- [Дока хранилища](../storage.md) — раскладка по шардам, конвенции имён,
часовые пояса.
- Мастер-спека, разделы 1.2 и 7 — `VisitID` как эталон самопроверки
(принятое отступление приёмки), карта таблиц.
- [CONTEXT.md](../../../CONTEXT.md) — «Визит», «Граница суток», «День
активности».
+266
View File
@@ -0,0 +1,266 @@
# Слой DM: витрины
Как модель DDS превращается в витрины для потребителя BI: какую форму
принимает витрина, кто какие витрины строит и как слой проверяется. Состав
колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма,
наполнение и контроль.
## Форма: по зерну, не по цене запроса
Витрина, меняющая зерно источника — дневной агрегат, сверка по заказам, —
материализуется: `dm.<имя>_rep` / `_dist` и публичное `dm.<имя>_v` с явным
списком колонок, по общим [конвенциям хранилища](storage.md). Наполняет её
`dm_transform`; ритм и механика пересборки — ниже. Витрина, сохраняющая зерно
источника — переименование, обогащение строки, — остаётся обычным
представлением: копию событий ради других имён колонок не держит ни один
боевой склад.
Правило выбрано учебной целью, а не ценой запроса. На объёмах стенда
представлениями обошлось бы всё: условия сквозь цепочку представлений
проходят ([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
Но пересборка агрегатов конвейером — производственный паттерн, ради
которого стенд и строится, а пара «таблица рядом с представлением»
показывает: форма — решение по роли витрины, у одного слоя законны обе.
## Кто строит: стенд и менти
Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и
инфраструктуру контроля:
- `revenue_daily` — выручка по дням и категориям; образец материализованной
витрины, на него ссылаются учебные задания;
- `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ,
шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`;
- `daily_traffic` — пара «посетители» / «известные пользователи», обогащение
[картой идентичностей](dds/identity.md);
- `dq_summary` — сводка проверок качества (ниже).
Остальные витрины спеки — учебные задания: менти строит их сам по ТЗ с
образцом. Разминка — представление `events_enriched_v` (зерно события),
затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`,
последней — самая содержательная `utm_effectiveness` с уроком `declared_*`.
Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
наравне со стендовыми. У разминочного представления такого пункта нет:
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
от выполненных домашек. Свой дашборд по витринам менти —
[необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
стендового дашборда — в [доке зоны Superset](superset.md).
## Ритм пересборки
День витрины пересобирается, пока может измениться его источник, —
поэтому ритмов три, по числу источников:
- **от событий и сессий** — день замерзает сразу; пересборка догоняет
хвост несобранных дней заменой дневных партиций;
- **от заказов** — день дышит, пока входит в последний слепок; пересборка
заменяет партиции этого отрезка, дни за ним закрыты по построению;
- **от карты идентичностей** — не замерзает никогда: склейка
ретроспективна, покупка сегодня делает куку известной во всех её прошлых
днях, а карта пересчитывается целиком.
Поэтому `daily_traffic` наследует у своего источника и ритм, и механику:
полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`,
как у самой карты. Дневных партиций у неё нет: партиция — единица замены,
а эта витрина заменяется только целиком. На стенде такая работа остается
быстрой; витрина читает сессии и карту через их публичные договоры.
Пересчитывать точечно только затронутые дни не из чего: для этого надо
знать, какие связки новые, а полный пересчёт карты этот след затирает —
восстановление следа оставлено лабе повышенной сложности (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_v` — «день данных × атомарная проверка».
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
Деловые колонки сводки:
- `data_date` — проверяемый день;
- `check_name` — устойчивое имя инварианта;
- `reference_rows` — число деловых ключей на эталонной стороне;
- `actual_rows` — число деловых ключей в проверяемом объекте;
- `failed_rows` — число отсутствующих или различающихся ключей;
- `status` — `pass` при нуле нарушений, иначе `fail`.
Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление
перечисляет колонки явно. Порогов и промежуточного состояния нет.
## Звено DQ конвейера
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
атомарных договоров:
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
`dds.session_v`;
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
`orders` в описи мира, объект — число заказов класса в
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
`duplicate_event` и `amount_delta` только для покрытых описью закрытых дней;
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
поля сверки сравниваются с независимой сборкой из `dds.event_v` и
`dds.order_v`;
- `daily_traffic_vs_dds`: ключ — день. `visitors` и `known_users`
сравниваются с прямыми агрегатами `dds.session_v` и
`dds.identity_map_v`.
Область каждой проверки выводится из объединения сторон. Полностью потерянный
день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут
скомпенсировать друг друга общим итогом: `failed_rows` считается после
сопоставления деловых ключей.
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет
сам генератор: у настоящего магазина такого оракула нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow
только сводку и малый срез диагностики. Техническая сверка с описью читает
небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их
успеха даг заменяет проверенные дневные партиции. Затем именованные задачи
читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
сохраниться до красного завершения, а ошибка сборки не затирает прежний
результат. Красная задача печатает не больше двадцати нарушенных ключей за
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
business_key`.
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
Такой день удаляется из сводки отдельно: старая красная строка не должна
превращаться в историю попыток.
## Даг самопроверки
Домашки контролирует `homework_check` — отдельный даг без расписания: менти
запускает его рукой. По задаче на каждое из
[пяти заданий по витринам](../labs/README.md).
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
лабы, а не история качества конвейера.
Приём проверен на соседнем учебном стенде Airflow + Greenplum:
валидационный даг с группами проверок по слоям давал менти обратную связь
по каждому шагу домашки, не трогая рабочий конвейер.
## Отклонённые варианты
- **Весь слой представлениями.** Дёшево и всегда свежо, но главный
производственный паттерн слоя — пересборка агрегатов — остался бы
невидим, а `dm_transform` — без работы.
- **Материализовать всё.** Копия событий ради переименования колонок —
антипаттерн, который менти увёз бы в бой.
- **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах
стенда нет: сторож без настоящей опасности.
- **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до
выполнения лаб, а проверка, которая всегда красная, приучает себя
игнорировать.
- **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого
состояния; изображать его логами — семантика, которую пришлось бы
расшифровывать. Красное «ещё не построено» честнее.
- **Самопроверка пишет в `dq_summary`.** Попытки менти смешались бы с
историей качества конвейера.
- **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
бы число известных на момент закрытия — витрина тихо расходится с картой
и протаскивает обратно «знание на дату», отвергнутое развилкой карты
(#133).
- **Инкрементальный пересчёт затронутых дней по `first_matched_at`.**
Производственный ход, но на объёмах стенда его экономия неизмерима, а
след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой
повышенной сложности — issue #149.
- **Superset поверх DDS без слоя DM.** Терялся бы урок «витрина — контракт
под потребителя», против раздела 7 спеки.
## Связанные решения
- [Мастер-спека](../specs/2026-07-30-stand-v2-realism.md), разделы 4 и 7, —
состав колонок витрин и классы сверки.
- [Дневной конвейер](etl.md) — запуск `dm_transform`, объём прогона,
замена партиций на кластере.
- [Дока хранилища](storage.md) — имена объектов, конвенция чтения через `_v`,
партиция-донор.
- [Карта модели DDS](dds/README.md) — источники витрин.
- Резолюция — тикет #135 карты #127.
+173
View File
@@ -0,0 +1,173 @@
# Дневной конвейер трансформаций
Как принятые источники превращаются в модель DDS и витрины DM: кто запускает
трансформации, когда и в каком объёме. Правила самих сущностей — зерно,
движки, что именно пересобирается — живут в доках слоя
([карта модели DDS](dds/README.md)); здесь — только конвейер.
Дневная сборка вперёд появляется на этапе 4 и остаётся основным режимом
конвейера. На этапе 5 к ней добавляется партиционная переобработка: запуск
произвольного прошлого дня по команде.
## Форма
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы
у него нет: он дёргает слойные даги последовательно и ждёт каждый —
`dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в
[доке слоя DM](dm.md).
Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по
ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что
оператор опрашивает точный созданный им прогон и краснеет вслед за потомком.
Слойные даги устроены по образцу `orders_ingest`: без расписания, один
активный прогон, запускаются триггером конвейера или рукой при разборе
происшествий. Витрины внутри `dm_transform` — отдельные задачи.
## Три пути запуска
```text
world_initialize ─┐
world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transform ─> dq_check
запуск рукой ─────┘
world_next_day ─────> источники без ETL
```
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
зеленеть, не узнав, разложился ли день, работник не должен
([ADR 0003](../adr/0003-dag-run-state.md)).
Такой отказ чинится перезапуском `etl_pipeline` рукой — перезапуску ничего
не нужно помнить, см. «Объём прогона».
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: пульт управляет
генератором и только им. После разгона менти запускает конвейер сам —
и видит, как слои догоняют уехавший вперёд мир.
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
ручного запуска начинаются со следующих дней.
## Объём прогона
Параметров у конвейера нет: объём он выводит сам из `world_position` и
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по
последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта
идентичностей пересчитывается целиком; правила витрин — у
[слоя 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` проходит два барьера за общий срок в 300 секунд.
Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы
`clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
раздела и все смещения должны существовать. Суммарное отставание должно стать
нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
Только после этого шаг проверяет обе ноды ClickHouse через
`clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не
должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и
`last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят
один крайний срок. После Kafka срок не отсчитывается заново.
Зелёный приём заказов о событиях не говорит ничего — топики разные и
потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог
подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард.
Два барьера закрывают обе части пути до того, как сессии выберут свой объем.
## Идемпотентность
Партиция не заменяется простой вставкой. День собирается в таблицу-двойник —
вставка через Distributed раскладывает строки по шардам, — затем
`REPLACE PARTITION` на кластере атомарно подменяет кусок каждого шарда. Эта
механика — часть самих трансформаций и строится этапом 4: без неё дневной
прогон не повторяем. Сущности, пересчитываемые целиком, идемпотентны и без
замены: полная перезапись в `ReplacingMergeTree` — так живут карта
идентичностей и витрина `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` с
обеих сторон. Трансформации такую сверку не поручают намеренно: она собирает,
а проверяет другой. Так две роли остаются различимы, и ни одна не
притворяется другой.
## Не входит
- Партиционная переобработка — переиграть произвольный прошлый день или
диапазон, включая форму необязательных параметров того же дага.
- Инкрементальный ETL вместо пересборки отклонен (#2, wontfix). Идея отдельной
лабы на эту тему — #149.
- Состав витрин `dm_transform` и проверок `dq_check` —
[дока слоя DM](dm.md).
## Отклонённые варианты
- **Задачи трансформаций прямо в работниках пульта.** Один зелёный прогон —
весь день, но пульт — про ось времени: работников два, шаги дублировались
бы, а этапу 5 пришлось бы выдирать их обратно.
- **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с
Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом
режиме слои копили бы отставание молча.
- **Временный даг с нейтральным именем до этапа 5.** Дневная сборка вперёд
никуда не денется, переезд был бы переименованием ради выдуманной границы.
- **Диапазон дней параметрами триггера.** Ручному запуску менти параметры
передавать некому, а перезапуск после падения требовал бы помнить conf
упавшего прогона.
- **Принять гонку с хвостом `hits`.** Дыра всплыла бы в DQ-сверке, а
чиниться — не раньше этапа 5.
## Связанные решения
- [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не
переживает отказ выше: отсюда ожидания в триггерах.
- [ADR 0009](../adr/0009-world-control.md) — пульт мира: его работник
живого дня и дёргает конвейер.
- [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему
прикладным процессом владеет Airflow и почему `world_initialize` кончается
собранными слоями.
- Резолюция — тикет #134 карты #127; что пересобирает каждая сущность —
[заказ](dds/order.md), [сессия](dds/session.md),
[карта идентичностей](dds/identity.md).
+59
View File
@@ -0,0 +1,59 @@
# Генератор кликстрима
Путеводитель показывает, как пройти генератор от корневого зерна до двух
выходов дня, не пересказывая код и спеки.
## Целевая картина одним взглядом
- **Подпоток определяется местом в дереве, а мир внутри подпотока — порядком
бросков** — [дерево зерен и порядок бросков](seeds.md).
- **Мир поднимает только когорты окна, нужные запрошенному дню** —
[ленивость мира](lazy-world.md).
- **Устройство и город принадлежат куке на всю жизнь и потому решаются
планом состава** — [паспорт куки](cookie-passport.md).
- **Выбор по весам сведен к целым броскам и арифметике интервалов** —
[целочисленная случайность](randomness.md).
- **Торговля получает готовые строки страниц и возвращает поток событий
вместе с покупками для бэкенда** — [шов торговых событий](commerce-seam.md).
- **Сутки принадлежат поясу счетчика, а `UTCEventTime` остается абсолютной
меткой** — [время модельного дня](counter-time.md).
```mermaid
flowchart LR
seed["Корневое зерно"] --> streams["Подпотоки"]
streams --> cohorts["Когорты"]
cohorts --> audience["Аудитория дня"]
audience --> visits["Визиты"]
visits --> output["Выход day.stream"]
output --> events["Поток событий"]
output --> backend["Заказы дня<br/>orders.of_day"]
```
За правой ветвью начинается отдельный набор документов —
[устройство заказов](../orders/README.md).
## Живой проход
Из каталога `generator/` одна команда собирает канонический D0 и показывает
все стадии на одном экране:
```sh
make demo
```
Когорту и аудиторию пример получает через открытый интерфейс плана, а визиты
считает группировкой выходных строк по `VisitID`. Код прохода —
[`demo.main`](../../../generator/src/clickstream_generator/demo.py).
## Родословная
Устройство генератора и основания решений записаны в [спеке этапа
2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки —
в [мастер-спеке стенда](../../specs/2026-07-30-stand-v2-realism.md).
Основания трех решений записаны в
[ADR 0015](../../adr/0015-counter-timezone.md),
[ADR 0016](../../adr/0016-cookie-passport.md) и
[ADR 0017](../../adr/0017-order-numbering.md), а спека этапа 2 закрыта.
Путеводитель появился по тикету
[#48](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/48):
он отвечает на другой вопрос — как пройти принятое устройство по коду.
@@ -0,0 +1,34 @@
# Шов торговых событий
Этот документ отвечает на вопрос, что день передает торговой части и что
получает от нее назад.
[`day.stream`](../../../generator/src/clickstream_generator/day.py) сначала
собирает выровненные по строкам ряды трафика:
- `columns` — готовые колонки просмотров страниц;
- `page` — вид страницы магазина;
- `product` — номер товара у карточки;
- `person` — человек за кукой строки.
`page` указывает, куда может сесть торговое событие, а `product` — какой товар
посетитель действительно открыл. `person` в кликстрим не выходит: он нужен
только для второй проекции покупки — заказа бэкенда.
[`commerce.weave`](../../../generator/src/clickstream_generator/commerce.py)
принимает эти ряды, вплетает `add_to_cart` и `purchase`, снова упорядочивает
поток и возвращает обновленные `columns`, `page`, `product` вместе с
[`commerce.Purchases`](../../../generator/src/clickstream_generator/commerce.py).
`Purchases` — узкая передача следующей стороне. В ней лежат номер заказа,
купленные товары и количества, клиентская выручка, промокод, момент события и
человек. Все они получены из тех же покупок, что уехали в поток, а не разобраны
назад из JSON.
[`orders.of_day`](../../../generator/src/clickstream_generator/orders.py)
принимает `Purchases`, а [`day.Day`](../../../generator/src/clickstream_generator/day.py)
отдает заказы рядом с колонками событий. Здесь поток дня расходится на две
ветви, показанные в индексе.
За этой границей начинается [устройство заказов](../orders/README.md). Судьба,
слепки и деньги бэкенда описаны там.
@@ -0,0 +1,29 @@
# Паспорт куки
Этот документ отвечает на вопрос, почему паспорт куки решает план состава, а
день только разворачивает его в события.
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) получает
случайность через
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
и вызывает [`plan._passports`](../../../generator/src/clickstream_generator/plan.py)
в ветви «состав мира». Это не подпоток отдельного дня.
`plan._passports` выбирает строки устройства и города из справочников. У
двухкуковой пары город первой куки переносится во вторую, а устройство
выбирается из другой половины профилей. Сведения о парах есть только у плана.
Если независимо вывести паспорт каждой куки из `ClientID`, один человек может
оказаться в двух городах — это будет ложью в данных.
[`plan.Cohort`](../../../generator/src/clickstream_generator/plan.py) хранит
номера устройства и города. `Cohort.visitors_on` переносит их в
[`plan.DayAudience`](../../../generator/src/clickstream_generator/plan.py), а
[`day.stream`](../../../generator/src/clickstream_generator/day.py) разворачивает
профили в колонки событий. День не бросает паспорт заново, поэтому кука
сохраняет его на всю жизнь.
Броски паспорта стоят после прежнего состава когорты. Правило такого
продолжения подпотока описано в [дереве зерен](seeds.md), а выбор
паспорта объясняет [ADR 0016](../../adr/0016-cookie-passport.md). Зачем стенду
нужна двухкуковая пара одного человека, фиксирует
[мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5.
@@ -0,0 +1,40 @@
# Время модельного дня
Этот документ отвечает на вопрос, почему дата модельных суток не обязана
совпадать с UTC-датой события.
В коде встречаются три координаты времени:
- D — номер дня на оси модельного времени;
- `EventDate` — дата D в поясе счетчика;
- `UTCEventTime` — абсолютная метка события.
Местное время гостя задает форму активности внутри суток, а границу дня задает
пояс счетчика.
[`world.ORIGIN`](../../../generator/src/clickstream_generator/world.py) — D0
календарной оси: он переводит номер D в конкретную дату. Это не механизм
часового пояса. Механизм —
[`world.COUNTER_TIMEZONE_MINUTES`](../../../generator/src/clickstream_generator/world.py),
смещение пояса счетчика от UTC.
В [`day._columns`](../../../generator/src/clickstream_generator/day.py)
`EventDate` получается как `ORIGIN + D`. Чтобы получить абсолютную полночь
тех же суток, код вычитает смещение счетчика, а затем прибавляет секунды
события внутри дня. Поэтому сутки `2026-06-01` в UTC+4 начинаются в
`2026-05-31 20:00:00Z`: у первых четырех часов
`toDate(UTCEventTime) != EventDate`.
[`day._starts`](../../../generator/src/clickstream_generator/day.py) сначала
выбирает час из городской строки весов, собранной
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py),
а затем равномерно выбирает секунду внутри часа. Строка поворачивает суточную
волну на разницу между поясом гостя и поясом счетчика. Так вечерний пик гостя
попадает в нужные часы внутри дня, но граница суток и `EventDate` остаются
счетчиковыми.
Как часовой пояс меняет представление времени в ClickHouse, описано в разделе
«Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл
полей записаны в [описании события](../../formats/clickstream-event.md).
Выбор модели времени объясняет
[ADR 0015](../../adr/0015-counter-timezone.md).
+26
View File
@@ -0,0 +1,26 @@
# Ленивость мира
Этот документ отвечает на вопрос, как любой день собирается без прожитой
истории и без глобального списка посетителей.
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) строит
одну когорту по зерну и дню ее появления. Отрицательные дни обозначают
предысторию; ее глубину ограничивает
[`world.RETURN_TAIL_DAYS`](../../../generator/src/clickstream_generator/world.py).
Запоминание уже рассчитанных когорт ускоряет соседние вызовы, но не становится
состоянием мира: очистка кеша не меняет результат.
[`plan.audience`](../../../generator/src/clickstream_generator/plan.py) для
дня D спрашивает `Cohort.visitors_on(D)` у когорт от
`D - RETURN_TAIL_DAYS` до D и соединяет полученные части. Поэтому вызов для
D0 поднимает всю предысторию в окне — 91 когорту от D−90 до D0.
Когорты предыстории дают D0 вернувшихся посетителей, но собственных событий
не порождают. Ось событий начинается в D0. Окно активности отсчитывается от
первого дня человека и остается общим для обеих его кук; за краем окна
когорта больше не может попасть в аудиторию.
Отсюда самостоятельность дня: D500 и D5 заглядывают назад на одинаковое число
когорт. Ни плану состава, ни день-функции горизонт мира не нужен.
Зачем приняты предыстория и ограниченное окно, объясняет [спека
генератора](../../specs/2026-08-01-generator.md), раздел 1.
+41
View File
@@ -0,0 +1,41 @@
# Целочисленная случайность
Этот документ отвечает на вопрос, как читать целочисленный выбор по весам и
зачем генератору свой маленький `weights.py`.
За `PCG64` и целые броски `Generator.integers` отвечает NumPy.
Генератор стенда не пользуется готовым выбором по вещественным вероятностям и
плавающими распределениями: их алгоритмы и системная математика могут
различаться между версиями и архитектурами. Зависимости закреплены, а броски
остаются целыми — так выполняется побайтовое обещание, записанное в [спеке
генератора](../../specs/2026-08-01-generator.md), раздел 2.
## Одна таблица весов
[`weights.pick`](../../../generator/src/clickstream_generator/weights.py)
сначала получает накопленные веса. Для весов `2, 3, 1` это границы
`2, 5, 6`. Затем он бросает целое число из полуинтервала `[0, 6)` и находит
первую границу справа:
- броски `0–1` выбирают вариант 0;
- броски `2–4` выбирают вариант 1;
- бросок `5` выбирает вариант 2.
Размер участка и есть вес варианта. Нормировать веса до долей единицы не
нужно.
## Своя таблица для каждой строки
[`weights.pick_row`](../../../generator/src/clickstream_generator/weights.py)
работает так же, когда у каждого броска свой ряд весов. Ряды с одинаковой
суммой укладываются друг за другом. Номер ряда смещает бросок на его участок,
единый поиск находит ячейку, а обратное смещение возвращает номер варианта.
Конкретный проход виден в
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py)
и [`day._starts`](../../../generator/src/clickstream_generator/day.py): для
каждого города суточная волна повернута в свой ряд, но сумма весов от поворота
не меняется.
При чтении вызова `pick` полезно представлять ряд целочисленных участков:
бросок выбирает вариант, в чей участок он попал.
+61
View File
@@ -0,0 +1,61 @@
# Дерево зерен и порядок бросков
Этот документ отвечает на вопрос, почему место подпотока стабильно, а порядок
бросков внутри него менять нельзя.
## Два порядка
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
и [`seeds.day_stream`](../../../generator/src/clickstream_generator/seeds.py)
находят ветвь по позиции `SeedSequence(entropy, spawn_key)`. Поэтому результат
ветви не зависит от того, какую соседнюю ветвь вычислили раньше.
Внутри найденной ветви живет один `Generator`. Каждый его вызов забирает
следующие значения. Поэтому порядок вызовов меняет результат: прежний
пятый бросок после вставки становится шестым и достается другому свойству
мира.
Дерево целиком описано в модуле
[`seeds`](../../../generator/src/clickstream_generator/seeds.py), а
[`seeds.Component`](../../../generator/src/clickstream_generator/seeds.py)
задает его нижний уровень — четыре подпотока дня:
```text
корневое зерно
├── состав мира
│ ├── ось событий → номер дня → когорта
│ └── предыстория → глубина → когорта до D0
└── дни
└── номер дня
├── TRAFFIC
├── COMMERCE
├── ORDERS
└── BEACONS — потеря и повторная отправка событий
```
Отрицательному дню нельзя отдать отрицательную позицию, поэтому предыстория
получила отдельную ветвь. Компоненты дня тоже занимают позиции, а не получают
зерна в порядке запуска.
## Что ломает вставка
Новый бросок в середине функции сдвигает все последующие значения этого
подпотока. Код продолжает работать, но тем же свойствам достается другая
случайность. Соседние подпотоки остаются прежними, а измененная часть
канонического мира проявляется в описи и побайтовом сравнении.
Какое обещание воспроизводимости это защищает, записано в
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
различать два свойства: `SeedSequence` стабилизирует адрес ветви, но порядок
бросков внутри `Generator` стабилизирует только сам код.
## Броски последними
Новое свойство продолжает существующий подпоток после прежних бросков. Такой
бросок не отнимает значения у уже рассчитанных свойств. В
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) паспорт
куки был дописан после состава и назначенных заказов, а личность человека —
после паспорта. Поэтому прежние ряды остались прежними.
Почему паспорт вообще принадлежит этой ветви, разобрано отдельно в
[документе о паспорте куки](cookie-passport.md).
+104
View File
@@ -0,0 +1,104 @@
# Мониторинг: из чего собран и за чем следит
Набор описывает зону мониторинга: какие сервисы в неё входят, что собирает
Prometheus, какие источники данных подготовлены в Grafana, каким пользователем
она ходит в ClickHouse и куда кладутся дашборды. Устройство каждого дашборда —
отдельным файлом, указатели ниже. Зачем зоне именно такой состав и какие
дашборды на нём задуманы — [ADR 0002](../../adr/0002-monitoring-scope.md);
устройство доступа — [ADR 0007](../../adr/0007-clickhouse-access.md).
**Что здесь есть и чего ещё нет.** Собран пол: свой образ Grafana с плагином
ClickHouse, два подготовленных источника данных, экспортёр метрик Kafka и цель
для него у Prometheus. Поверх пола стоят все три дашборда ADR 0002, они
перечислены ниже. Правил оповещения и серверного журнала `system.text_log`
пока нет.
## Состав
| Сервис | Образ | Что делает |
|---|---|---|
| `prometheus` | `prom/prometheus:v3.13.2` | опрашивает цели раз в 15 секунд, порт стенда `29090` |
| `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` |
| `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` |
Плагин ClickHouse 4.20.0 входит в образ Grafana. При сборке Grafana CLI берёт
закреплённый выпуск из официального репозитория на GitHub, а не из каталога
Grafana: прямой запрос к каталогу при проверке вернул HTTP 451. Фоновая
установка плагинов при запуске отключена.
## Кого опрашивает Prometheus
| Цель | Задание | Метка `role` | Откуда метрики |
|---|---|---|---|
| `clickhouse-01:9363` | `clickhouse` | `server` | встроенный экспортёр ноды |
| `clickhouse-02:9363` | `clickhouse` | `server` | встроенный экспортёр ноды |
| `clickhouse-keeper:9363` | `clickhouse-keeper` | `keeper` | встроенный экспортёр keeper |
| `kafka-exporter:9308` | `kafka` | `exporter` | внешний экспортёр |
Ровно эти четыре цели пересчитывает `check_prometheus_targets` в
`scripts/stand-smoke.sh` — и по значению `up`, и по списку адресов. Пятая цель
красит смоук; это и есть способ заметить, что состав пола поменяли молча.
## Источники данных Grafana
Оба источника подготовлены файлами в `infra/grafana/provisioning/datasources/`
и объявлены `editable: false`: править их через интерфейс нельзя, источник
истины — репозиторий.
- `prometheus` — основной по умолчанию, ходит на `http://prometheus:9090`.
- `clickhouse` — плагин `grafana-clickhouse-datasource`, нативный протокол,
`clickhouse-01:9000`, пользователь `grafana`, пароль из `.env`.
Источник ClickHouse смотрит на `clickhouse-01`. Системные таблицы обеих нод
панели читают через `clusterAllReplicas`, как принято в ADR 0002.
## Доступ к ClickHouse
Grafana ходит пользователем `grafana` с ролью `monitoring_reader`: читает слои,
таблицы базы `dic` и системные таблицы. `REMOTE` разрешает
`clusterAllReplicas`, `CLUSTER` — запросы `ON CLUSTER`. Решение о своей роли
записано в [ADR 0007](../../adr/0007-clickhouse-access.md). Пароль задаётся
переменной `CLICKHOUSE_GRAFANA_PASSWORD`.
## Дашборды
Каталог `infra/grafana/provisioning/dashboards/` смонтирован в контейнер и
объявлен провайдером в `provider.yml`: Grafana подхватывает оттуда файлы
дашбордов, удалять и править их через интерфейс запрещено. Новый дашборд — это
новый файл в каталоге и больше ничего: в `compose.yaml` заходить не нужно, ради
этого каталог и заведён заранее.
Файлы каталога разбирает `make config-test`, рядом с файлами дагов и по тому же
доводу ([testing.md](../testing.md)).
Порогов и цветовых вердиктов на панелях нет ни на одном дашборде: на них
смотрят глазами, красное и зелёное живёт в проверках и дагах DQ.
Собранные дашборды:
- **«Данные»** (`data.json`) — с потоком всё в порядке: свежесть, поток, брак,
сходимость Kafka с ClickHouse, отставание чтения и дневной слепок заказов.
Устройство и разбор панелей — [data.md](data.md).
- **«Кластер»** (`cluster.json`) — что с машиной: процессор, память и диски
обеих нод, части и слияния, keeper, очередь распределённых DDL. Устройство и
разбор панелей — [cluster.md](cluster.md).
- **«Запросы»** (`queries.json`) — что выполнялось и во что обошлось: тяжёлые
запросы, аппетит по посетителям, ошибки. Устройство и разбор панелей —
[queries.md](queries.md).
## Что проверено
Права роли `monitoring_reader` сверены 21 августа 2026 года по документации
ClickHouse через MCP Context7; разбор — в
[ADR 0007](../../adr/0007-clickhouse-access.md), раздел «Что проверено».
Сам состав проверен на живом стенде 21 августа 2026 года, собранном заново
после `make clean`.
- Grafana видит плагин `grafana-clickhouse-datasource` 4.20.0
(`/api/plugins?type=datasource`), оба подготовленных источника здоровы.
- Запрос источника ClickHouse к `system.one` через `clusterAllReplicas` вернул
2 — с одной ноды видны обе.
- Prometheus видит ровно четыре цели, все со значением `up = 1`; запрос
`kafka_consumergroup_lag` вернул ряды по группам `clickstream_*`.
- `make smoke` 21/21, `make check-clickhouse` 10/10, `make check-services` 7/7.
+156
View File
@@ -0,0 +1,156 @@
# Дашборд «Кластер»
`infra/grafana/provisioning/dashboards/cluster.json` — семь панелей. Вопрос у
экрана один: что с машиной? Состав панелей и довод за каждую —
[ADR 0002](../../adr/0002-monitoring-scope.md).
Свой вопрос каждая панель называет заголовком прямо на экране. Здесь — только
источники, о них и речь ниже.
| Панель | Откуда берёт |
|---|---|
| Процессор | `system.metric_log` |
| Память | `system.asynchronous_metric_log` |
| Диски | `system.metric_log` |
| Части | `system.parts` |
| Слияния | `system.part_log` |
| Keeper | встроенные метрики keeper через Prometheus |
| Распределённый DDL | `system.distributed_ddl_queue` |
Окно у дашборда — полчаса, обновление — полминуты. Пачка из
`make generate-batch` проходит за двадцать секунд, и на получасовой оси это
узкий всплеск в пару столбиков. Шире окно не берут, чтобы всплеск не выродился
в точку; уже — чтобы не промахнуться мимо него, заглянув через минуту, и чтобы
рядом осталось спокойное время: подъём читается только над полом. Кто хочет
разглядеть форму вплотную, растягивает окно мышью по самому всплеску.
Панель частей окна не знает вовсе. `system.parts` — опись живых частей, и
вопрос к ней про сейчас: колонки со временем в ней есть, но ни одна не годится
в ось. Очередь DDL окна тоже не знает, но по другой причине: она показывает
последние двадцать записей, потому что в спокойные полчаса пуста, а пустая
панель ничего бы не рассказала.
## Обе ноды на одной панели
Системная таблица у каждой ноды своя: `system.metric_log` на `clickhouse-01`
знает только про `clickhouse-01`. Запрос без обёртки показал бы половину
правды и ничем бы себя не выдал — вторая нода просто не появилась бы в легенде.
Поэтому запросы идут через `clusterAllReplicas`, а `hostName()` подписывает,
чья это строка.
Исключений два, и они разной природы. Очередь распределённых DDL лежит не у
ноды, а в keeper, и обе ноды читают из него одну и ту же очередь целиком,
вместе со строками про соседа. Обёртка здесь не добавила бы правды, а удвоила
бы каждую строку — это единственная панель дашборда без неё. Правило поэтому
звучит так: `clusterAllReplicas` нужен там, где у каждой ноды своя правда.
Второе исключение мягче. Панель слияний читает обе ноды той же обёрткой, но
складывает их в один ряд: шарды сливают симметрично, и разделение удвоило бы
легенду ради двух одинаковых кривых.
## Пол на графиках — журналы самого сервера
ClickHouse ведёт около десятка собственных журналов: `metric_log`,
`trace_log`, `text_log`, `query_log` и соседние. Все они — таблицы MergeTree:
их пишут и сливают постоянно, даже когда на стенде не происходит ничего.
Отсюда процессор и диски, которые никогда не падают в ноль.
Вычесть этот пол на процессоре и дисках нечем: их счётчики знают, сколько
работы сделал процесс, и не знают, ради какой таблицы. Своя нагрузка на этих
двух панелях читается как подъём над полом, а не как число само по себе.
У частей и слияний пол отделим: в `system.parts` и `system.part_log` у каждой
строки написано, какой таблице она принадлежит. Обе панели поэтому показывают
всё, кроме журналов сервера, — и у каждой на это своя причина.
Столбик у слияний шириной в минуту, а не в шаг панели. Слияния — события
редкие: на своём шаге столбик выродился бы в волосяную линию, и четыре события
за полчаса пришлось бы искать глазами.
Слияниям фильтр иначе не пережить по размеру: за спокойные полчаса они съели
6 492 части в системных журналах против 219 в `ods` и `stg` вместе. Оставь мы
журналы в кадре, ряды слоёв прижались бы к нулю, и панель рассказывала бы про
уборку за сервером вместо приехавших данных. Сколько эта уборка стоит, видно
этажом выше, на процессоре и дисках.
Частям фильтр нужен для другого: без него в таблице десятки строк, а значит
нужен предел — и предел рвёт пару нод пополам, оставляя таблицу с одной нодой в
кадре. С фильтром помещаются все, и предел не нужен.
## Что именно меряют процессор, память и диски
Процессор считается по времени самого сервера
(`ProfileEvent_OSCPUVirtualTimeMicroseconds`), а не по загрузке машины.
Метрики с именами `OS*` сняты с хоста целиком: обе ноды показывали бы в них
одно и то же число, и панель на два ряда потеряла бы смысл.
Память показана резидентной (`MemoryResident`), а не той, что сервер считает
своей (`MemoryTracking`). Разница не косметическая: на праздной ноде
`MemoryTracking` — 113 МиБ при 846 МиБ резидентной памяти, а ядро, решая, кого
убить за нехватку памяти, смотрит на вторую величину
([ADR 0004](../../adr/0004-resource-limits.md)). По первой нода, которую
вот-вот убьют, выглядит совершенно здоровой.
Пунктирный ряд «отведено» — предел памяти контейнера, та самая коробка из
ADR 0004. Он тоже читается живьём, метрикой `CGroupMemoryTotal`: впиши мы
4 ГиБ числом в JSON, панель молча соврала бы в тот день, когда предел поменяют.
У дисков считаются обращения к файлам — `OSReadChars` и `OSWriteChars`.
Счётчики блочного устройства (`OSReadBytes`) для той же панели не годятся:
блочное чтение на стенде почти всё время нулевое, части лежат в страничном
кэше, и слияние перечитывает их оттуда. Работа при этом идёт настоящая, и
панель по блокам показывала бы её рваным пунктиром.
## Keeper и очередь DDL — то, что живёт вне нод
Keeper — отдельный сервис, системных таблиц у него нет, и панель по нему
единственная на дашборде приходит из Prometheus.
Показывает она размер дерева. В keeper лежит журнал репликации, а в нём —
запись на каждую часть: пока поток идёт, дерево растёт, а когда части
домержились и старые записи убраны, оседает. Замер за полчаса живого потока:
9 078 узлов на подъёме, 11 512 на пике, 10 308 через десять минут после того,
как поток встал.
Очередь распределённых DDL — вторая половина того же сюжета: через keeper
проходит каждый запрос `ON CLUSTER`. Заглядывают сюда с одним вопросом: доехал
ли он до обеих нод и что ответила каждая.
## Что проверено
Дашборд проверен на живом стенде 21 августа 2026 года.
- Дашборд поднимается из каталога сам, все семь панелей отвечают через
`/api/ds/query` в окне на полчаса. Пустых нет.
- Обе ноды видны на четырёх панелях из пяти, что читают системные таблицы нод.
Пятая — слияния, там ноды сложены намеренно. Без `clusterAllReplicas`
осталась только очередь DDL, и обёртка ей действительно не нужна: запрос с
ней вернул с `clickhouse-01` и с `clickhouse-02` одно и то же число записей —
на день замера 124 и 124.
- Под нагрузкой. `make generate-batch` (50 626 событий одной пачкой), замер в
десятисекундных вёдрах: процессор поднялся с 0,04–0,18 ядра до 0,54 на одной
ноде и 0,64 на другой, запись — с 400 КиБ/с до 2,1 и 4,5 МиБ/с. Ведро тут
часть ответа: в тридцатисекундном тот же прогон даёт 0,29 и 0,21 ядра.
Следующие десять секунд обе величины уже вернулись к прежним: на таком объёме
подъём и оседание укладываются в полминуты.
- Слияния под той же нагрузкой отозвались столбиком в ряду `stg` высотой в
15 съеденных частей.
- Блочное чтение действительно молчит: за минуту вокруг вставки `OSReadBytes`
был нулевым в десяти десятисекундных окнах из четырнадцати, а обращения к
файлам шли непрерывно во всех.
- Память: около 1 ГиБ занятого против 4 ГиБ отведённого на каждой ноде.
- Панель частей показала `ods.order_rep` по 142 строки в части против 13 046 у
`ods.event_rep` — заказы приезжают дневным слепком, события потоком. Строк
вышло восемь: четыре таблицы, каждая обеими нодами. Таблиц ошибок среди них
нет, и это правда состояния — частей у них пока не появилось.
- Очередь DDL показала `CREATE MATERIALIZED VIEW ... ON CLUSTER` в состоянии
`Finished` и со временем по каждой ноде. Новые безопасные записи для показа
оставляет `make check-clickhouse`: цель создаёт и удаляет временные таблицы
`ON CLUSTER`. Канонический DDL появляется в очереди при создании мира дагом
`world_initialize`.
Подписи рядов (`${__field.labels.node}`) и правило, по которому пунктирным
становится только ряд «отведено», сверены по документации Grafana через MCP
Context7. И то и другое применяется на стороне браузера, через
`/api/ds/query` их не видно: там проверено только, что нода приезжает меткой
ряда, а метрика — его именем.
+117
View File
@@ -0,0 +1,117 @@
# Дашборд «Данные»
`infra/grafana/provisioning/dashboards/data.json` — шесть панелей на один
экран. Вопрос у экрана один: с потоком всё в порядке? Состав панелей и довод за
каждую — [ADR 0002](../../adr/0002-monitoring-scope.md).
Свой вопрос каждая панель называет заголовком прямо на экране. Здесь — только
источники, о них и речь ниже.
| Панель | Откуда берёт |
|---|---|
| Свежесть | `ods.event_dist`, `ods.order_dist` |
| Поток | `stg.hits_raw_dist`, `stg.orders_raw_dist` |
| Брак | те же и `ods.*_errors_dist` |
| Сходимость | экспортёр Kafka и `stg.hits_raw_dist` |
| Отставание | `kafka_consumergroup_lag` |
| Дневной слепок | `stg.orders_raw_dist` |
## Почему слои у панелей разные
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань
разбор — свежесть ODS это покажет, свежесть сырья нет.
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
здесь про доставку. Местом диагностики доставленных версий физическая таблица
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего
не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события,
и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
сильнее, чем дольше живёт стенд.
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
отдельная куча, а часть сырья, которая не прошла разбор.
Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
приехавшего и однажды перевалила бы за сотню процентов.
Панель дневного слепка, наоборот, окна не знает. Её вопрос про состояние, а не
про отрезок, и последние десять слепков она показывает всегда.
Отдельного ряда «против вчерашнего» на панели потока нет, и это решение, а не
пропуск. Реальное «вчера» на стенде чаще всего приходится на время, когда
стенда ещё не было. А сдвинуть ряд на модельные сутки значит записать в JSON
дашборда ускорение мира — ровно ту пометку про тик, которую [ADR
0002](../../adr/0002-monitoring-scope.md) в дашборде держать не велит: поменяют
ускорение, и панель молча соврёт. Сравнение остаётся глазу: модельные сутки
укладываются в минуты реального времени, и соседние дни мира помещаются в одно
окно панели сами.
## Приземлившееся считают по слою сырья
Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам
экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka`
хранилищем не работают: у второго стоит `kafka_commit_on_select`
(`sql/ddl/10-stg-tables.sql`), и запрос из панели забрал бы слепок себе,
сдвинул офсеты, а даг приёма приехал бы к пустому топику.
## Как считается сходимость
Произведённое живёт в Kafka, приземлившееся — в ClickHouse, и со своей стороны
ни одна из них не видит другую. Поэтому панель смешанная: ряд «произведено в
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
стороны ClickHouse — `count()` строк сырья по тому же интервалу.
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
растягивает приращение на всё окно и на минутном интервале промахивается на
проценты: здоровый стенд выглядел бы расходящимся. Накопленный итог от начала
окна точнее обоих приростов, но отсчитывать его надо от края окна — а на
свежем стенде окно начинается раньше, чем появился экспортёр, и ряд пропадает
целиком.
Столбцы ClickHouse подписаны правым краем, а не левым. Точка Prometheus — это
прирост к моменту времени, а `count()` по интервалу — сумма, которая набирается
к его концу; подпиши мы её началом, здоровые ряды разъехались бы ровно на один
столбец. Шаг панели при этом не мельче минуты: экспортёр опрашивают раз в
пятнадцать секунд, и на более мелком шаге ряд Kafka пошёл бы ступеньками там,
где ряд ClickHouse идёт гладко.
Точка в точку ряды всё равно не совпадут, и ждать этого не надо: опрос
экспортёра не попадает на границы столбцов, поэтому на разгоне потока линия
Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в
один столбец не значит ничего.
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден
сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему
другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него
отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё
он приезжает полем `snapshot_date`.
## Что проверено
Дашборд проверен на живом стенде 21 августа 2026 года, `world_live` снят с
паузы.
- Дашборд поднимается из каталога сам: файл положен, панели появились без
ручного импорта. Сохранение и удаление через API отвергнуты («Cannot save
provisioned dashboard», «provisioned dashboard cannot be deleted»).
- Все шесть панелей отвечают через `/api/ds/query` — и в окне часа по
умолчанию, и в окне на шесть часов, которое начинается раньше, чем поднялся
экспортёр. Пустых нет ни при идущем потоке, ни при остановленном.
- Брак: подсунутое в топик `hits` негодное сообщение легло в
`ods.event_errors` с классом `keyset_mismatch`; панель показала единицу и
долю 0,039 % в десятиминутном окне. Строка убрана мутациями из
`stg.hits_raw_rep` и `ods.event_errors_rep`, обе таблицы вернулись к нулю.
- Сходимость: на живом потоке за двадцать пять минут медиана расхождения между
рядами 3 %, худший столбец 11 % — на переломе потока, как и описано выше.
- Свежесть реагирует на остановленный поток и возвращается. Через пять минут
после конца модельного дня панель показала 305 с у событий и 288 с у
заказов; стоило пустить следующий день, события вернулись к секунде.
Свежесть заказов при этом растёт до конца дня и садится, когда доедет его
слепок, — так и устроен второй источник.
+76
View File
@@ -0,0 +1,76 @@
# Дашборд «Запросы»
`infra/grafana/provisioning/dashboards/queries.json` — три панели поверх одной
таблицы, `system.query_log`. Вопрос у экрана один: что выполнялось и во что это
обошлось? Состав панелей и довод за каждую —
[ADR 0002](../../adr/0002-monitoring-scope.md).
Свой вопрос каждая панель называет заголовком прямо на экране. Здесь — только
то, чего в заголовке не скажешь.
Журнал у каждой ноды свой, поэтому все три панели читают его через
`clusterAllReplicas` — то же правило, что и на [«Кластере»](cluster.md).
Окно — час, обновление — полминуты. Час взят потому, что сюда приходят с
вопросом о прошлом: «почему у меня всё стояло десять минут назад». Полминуты —
чтобы только что запущенный тяжёлый запрос не пришлось ждать: в журнал он
попадает через несколько секунд после конца.
## Первым в списке видно сам мониторинг
Grafana ходит в ClickHouse обычным пользователем, и её запросы ложатся в
журнал наравне с человеческими. На спокойном стенде верх списка тяжёлых
запросов занимает `grafana`: панель свежести с дашборда «Данные» читает по
полмиллиона строк за каждое обновление.
Так и надо: мониторинг тоже стоит денег, и цену видно там же, где и всё
остальное. Спрячь мы свои запросы фильтром — цена ушла бы с глаз, а панель «Кто
спрашивает» недосчиталась бы самого частого посетителя.
Различать посетителей дашборд умеет потому, что у Grafana своя учётка со своей
ролью ([ADR 0007](../../adr/0007-clickhouse-access.md)). Ходи она под
аналитиком, панель «Кто спрашивает» показывала бы одну строку на всех.
## Что отброшено и почему
Каждый запрос попадает в журнал дважды: строкой начала и строкой конца. В
строке начала нет ни длительности, ни памяти, ни прочитанных строк — там их
ещё неоткуда взять. Панели считают по строкам конца.
Распределённый запрос кладёт в журнал не только себя. Инициатор пишет свою
строку, а каждый шард — свою, про доставшийся ему кусок. Две панели из трёх
считают только запросы инициатора: иначе один запрос человека выглядел бы
тремя, а прочитанные строки сложились бы дважды.
Панель ошибок, наоборот, куски шардов показывает: отказ случается там, где
считали, и строка шарда — единственное место, где эта нода названа. В замере с
принудительным пределом памяти `clickhouse-01` записал свой отказ, а
`clickhouse-02` — свой, и вторая строка видна только без фильтра по
инициатору.
## Пустой пользователь
Часть строк журнала приходит без имени пользователя: так записана собственная
работа сервера — чтение топиков движком Kafka, вставки материализованных
представлений, обслуживание. Панели подписывают такие строки «фоновая работа
сервера»: пустая клетка в таблице читается как поломка, а это не она.
## Что проверено
Дашборд проверен на живом стенде 21 августа 2026 года.
- Дашборд поднимается из каталога сам, все три панели отвечают через
`/api/ds/query` в окне на час. Пустых нет.
- Тяжёлый запрос виден. `GROUP BY` по `ods.event_dist` от учётки `analyst`
приехал в панель со своими числами: 741 мс, 53,32 МиБ памяти, 701 994
прочитанные строки.
- Ошибки видны и различаются по природе: отказ по правам (`bi` без `SELECT` на
`ods`) и ошибка в тексте запроса (несуществующая колонка) легли в панель
разными строками, каждая с полным текстом.
- Строка шарда в панели ошибок настоящая. Принудительный предел памяти на
распределённом `GROUP BY` дал две записи: `clickhouse-01` с
`is_initial_query = 1` и `clickhouse-02` с нулём. Вторая пропала бы, стой на
панели тот же фильтр, что на соседних.
- Панель «Кто спрашивает» разделила шесть учёток — `grafana`, `analyst`, `etl`,
`dict`, `bi`, `default` — и фоновую работу сервера. Больше всех прочитала
`grafana`: 11,5 млн строк за час, своими же панелями.
+68
View File
@@ -0,0 +1,68 @@
# Заказы бэкенда
Устройство второго источника стенда: раз в модельный день бэкенд магазина
выгружает полный слепок заказов окна изменяемости, и деньги в витринах
считаются по нему, а не по трекеру. Набор собран картой #69 (этап 3) и
правится по мере постройки.
Границы уже решены мастер-спекой [«Боевой реализм стенда
(v2)»](../../specs/2026-07-30-stand-v2-realism.md): поля слепка, окно K = 7
как константа мира, три статуса, приоритет классов расхождений, правило
«поведение и атрибуцию считаем по трекеру, деньги — по бэкенду». Рамка, перед
которой отвечает каждое решение, — «Чем меряется генератор» в [спеке
генератора](../../specs/2026-08-01-generator.md): конструкция внутри
оправдана только наблюдаемым эффектом на выходе.
## Целевая картина одним взглядом
- **Заказ — проекция, не порождение.** Торговая половина дня-функции уже
посчитала корзину, цены, купон и номер заказа; заказная половина навешивает
судьбу и собирает слепок. Ни одного нового броска в торговом подпотоке —
[откуда берётся заказ](snapshot.md).
- **Слепок дня D — чистая функция (зерно, D)**: состояние заказов, рождённых
в дни D−6…D, снятое на границе суток D|D+1. Отправляет его следующий
прогон — ночная выгрузка бэкенда за вчера; на проводе — один JSON-документ
на заказ — [слепок и его доставка](snapshot.md).
- **Судьба заказа решается при рождении** и обязана уложиться в окно K либо
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
[судьба заказа](fate.md).
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
[классы расхождений и опоздание](fate.md).
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
каждого слепка и счётчики наблюдаемых классов —
[что хранит опись](inventory.md).
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
[мост к склейке](identity.md).
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order`
принимает версии заказа на `ReplacingMergeTree(snapshot_date)` —
[приём из Kafka в ODS](ingestion.md).
- **Дальше заказ живёт моделью DDS** — уже по хранилищную сторону границы,
в наборе слоя: [модель заказа](../dds/order.md).
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
дня клики есть, а заказов нет, и график выручки дозаполняется по ходу
мира — [заказы в стартовом мире](start-world.md).
Правила, обязательные для кода этапа 3, собраны в
[правилах кода](code-rules.md).
## Открытые решения
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
остаётся в тумане карты #69; естественное место разговора — этап 4.
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
механике заказов не привязан.
## Родословная
Собрано тикетом #87 по резолюциям развилок карты #69: приём (#70), генератор
слепков (#71), расхождения и опоздания (#72), мост к склейке (#73), место в
стартовом мире (#74), брак и версии в ODS (#80), форма записи на проводе
(#81). Решения о приёме — [ADR 0008](../../adr/0008-order-ingestion.md) и
[ADR 0010](../../adr/0010-order-versions-in-ods.md); контракт провода —
мастер-спека, раздел 2, и [исследование формата
слепка](../../research/2026-08-16-order-snapshot-wire-format.md). Форма
набора — [ADR 0011](../../adr/0011-component-docs.md).
+17
View File
@@ -0,0 +1,17 @@
# Правила кода этапа 3
Хвосты резолюций, обязательные для реализации:
- **Броски заказной стороны — на полную длину дня**, а не на отобранных
заказах (правило формы, [судьба заказа](fate.md)); моментов всегда два.
- **Целочисленная случайность** наследуется правилом кода этапа 2: таблицы
целых весов, никаких плавающих распределений; деньги — в целых копейках.
- **Подпотоки — по позиции в дереве**: стороны судьбы ветвятся по дню рождения
заказа; в `COMMERCE` новых бросков нет; `person_id` — последний бросок
когорты.
- **Окно K = 7 — константа мира** в конфигурации мира, рядом с D0 и поясом
([исследование
формата](../../research/2026-08-16-order-snapshot-wire-format.md)).
- **Один сериализатор**: запись слепка собирает явная функция `serialize.py`,
прямых `json.dumps` по коду нет.
- **Значения идентификаторов — ниже 2^53** (`user_id` наравне с прочими).
+155
View File
@@ -0,0 +1,155 @@
# Судьба заказа и расхождения
Резолюция развилки [«Расхождения A–D и опоздания: механика, доли и что
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72).
## Судьба заказа
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
решённая при его рождении и уложенная в окно K целиком.
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
дубль. Довод за разделение — различимость по описи: правка заказной механики
не двигает хеши событий, правка событийной не двигает байты слепка, и по
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
компонент называет часть мира; новые стороны занимают те же позиции.
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
событийной механики молча меняла бы байты слепка; *компонент на класс* —
пять имён под ручки калибровки, которые крутятся разом.
**Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
становится функцией доли, и правка одной доли перебрасывает весь подпоток
после себя. Изоляцию даёт форма броска, а не число подпотоков.
**Путь по статусам.** Три исхода, все внутри окна: **оплачен**; **оплачен и
отменён**; **не оплачен и отменён**. Инвариант на выходе из окна: заказ либо
`paid`, либо `cancelled`; `created` — только промежуточное состояние. За окном
будущего у заказа нет, а заказ, навсегда застрявший в `created`, — модельная
небрежность, которой в выгрузке живого магазина соответствия нет. Поэтому
нового значения `mismatch_class` не нужно: `cancelled` покрывает обе дороги
отмены (шестое значение занято `awaiting_order`).
**Форма броска.**
1. **Исход** — таблица долей из трёх строк; доля неоплаченных пишется явной
строкой, а не оставляется читателю складывать хвост в уме.
2. **Моменты** — таблица целых весов «сколько часов от рождения — с каким
весом», строки 0…143, плюс равномерная секунда внутри часа — чтобы разности
времён аудита не давали точных равенств (урок правки #50).
3. Моментов бросается **всегда два, на полную длину дня**; у одномоментных
исходов второй выбрасывается. Где их два по существу, ранний считается
оплатой — порядок выходит сортировкой, условной точки отсчёта не нужно.
143 часа — самый узкий край окна: у заказа, рождённого в конце суток, до
последнего его слепка 144 часа. Таблица кончается там, где кончается окно у
самого невезучего: вылезти нечему, сторожа не нужно. Цена — заказ, рождённый в
начале суток, не использует почти сутки своего окна; в хвосте таблицы веса
мизерные, в данных это не видно. Форма из #71 — «вес за краем окна означает
„не оплачен никогда“» — этим отменена: такой заказ теперь отменяется, а край
окна не выражается числом часов — иначе доля неоплаченных стала бы функцией
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
она делится между двумя дорогами — строки таблицы исходов. Замер на
каноническом мире (28 дней, 2026-08-30, docs/architecture/testing.md, «Что
проверено»): отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12%
отменен неоплаченным; в пределах ориентира. Проверок вида «отмен от 4 до 6
процентов» не заводим.
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии
`ods.order` до фоновых слияний, а в витринах — выручка дня, которая
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* —
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
он в резерве.
## Классы расхождений и опоздание
Классы и ориентиры долей — [мастер-спека,
раздел 4](../../specs/2026-07-30-stand-v2-realism.md); здесь — механика
каждого.
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
`items_total` меньше на её полную стоимость. После вычёркивания деньги заказа
считаются от оставшейся корзины по [общему
правилу](snapshot.md#откуда-берётся-заказ), поэтому `total` остаётся
неотрицательным по построению. Момента у дельты нет — заказ приезжает урезанным
во всех своих слепках: первый слепок снимается на границе суток, когда склад
заказ уже собрал. Заказ из одной позиции дельты не получает — пустых заказов
не бывает. Позиция выбирается равновероятно: корреляция со спросом на доле
1–2% статистически ненаблюдаема — менти платил бы за неё таблицей чисел мира,
а увидеть не мог бы ничем. Доводы за вычёркивание: остаток — сотни рублей, он
торчит в витрине сверки сам; расхождение объясняется сравнением позиций —
разбором вложенного JSON и `ARRAY JOIN`, ровно тем навыком, ради которого
позиции разбираются; история рассказывается словами без легенды про генератор.
Отклонено: *сохранять скидку исходной корзины и отбирать только безопасные
позиции* — отсутствие товара стало бы зависеть от купона и доставки, а выбор
позиции перестал бы быть равновероятным; *разрешить отрицательный `total` или
ослабить строгий приём* — ошибка источника превратилась бы в брак, который
молча терпит хранилище; *переоценка позиции* и *другое количество* — дельта в
десятки рублей, её надо захотеть заметить; *чистая дельта без истории* —
тупик, объяснить нечем; *врёт клиент, а не бэкенд* — заказ у нас проекция той
же корзины.
**Потеря события (B) — точечная.** Уходит строка `purchase`, просмотр
`/confirmation` остаётся: события уезжают разными запросами, потерять один и
сохранить другой — обычное дело. Единственный вариант, при котором потеря
видна со стороны трекера: до подтверждения дошли сто, покупок девяносто семь.
**Дубль события (D) — сюжетный.** Обновление страницы шлёт и просмотр, и
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
один в один — посчитал наивно, удвоил выручку. Дубль случается только там,
где пауза до следующего визита куки длиннее таймаута и задержки дубля вместе:
иначе сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон,
ради которого `VisitID` в потоке лежит. Задержка дубля —
секунды-минуты, короче таймаута визита, поэтому `VisitID` тот же. Полночь
режет дубль парой — просмотр вместе с покупкой, по тому же правилу, что у
подтверждения с торговым хвостом.
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
потеря перестаёт быть видна со стороны трекера.
**Гарантия моста сильнее порчи.** Назначенные планом покупки не теряются, и
назначенные планом заказы не опаздывают: `dds.identity_map` строится из моста
«`purchase` ↔ заказ», и выброшенное событие — как и заказ, не попавший ни в
один снятый слепок, — уносит куку из карты. Это был бы отказ лабы склейки, а
не расхождение в данных; менти различить не может.
**Опоздание — заказ прячется от ранних слепков.** `created_at` не
подделывается — строка создана, когда заказ родился, — но в слепках дней
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Таблица задержки
состоит из трёх строк: δ = 0/1/2 с весами 90/5/5; 1 и 2 — это D+1 и D+2
мастер-спеки. Бросок идёт последним в подпотоке `ORDERS`; затем δ назначенного
планом заказа обнуляется. Меряется задержка в днях снятия слепка, а не
отправки: иначе сдвиг отправки удвоился бы, и обещанные D+1/D+2 стали бы
D+2/D+3. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно в одном
слепке, и обещание «пропущенный день ничего не ломает» на нём перестало бы
быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Отклонено:
*сдвиг `created_at`* — подделка аудита источника: день создания строки
разошёлся бы с днём покупки, чьё равенство держит синхронная модель
([откуда берётся заказ](snapshot.md)), заказ уехал бы в чужую партицию,
и «выручка дня D» перестала бы отвечать покупкам дня D — сломалась бы та
самая сверка, ради которой всё строится.
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
решается только у выживших покупок, а назначенное планом не теряется и не
опаздывает — дельта и отмена ему разрешены, моста они не рвут.
Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные числа
(часть заказов забирают победители по приоритету, часть у класса C недоступна —
однопозиционных заказов больше половины). Отклонено: *один класс на заказ* —
приоритет в SQL стал бы мёртвой веткой, которую менти читает как живую.
+37
View File
@@ -0,0 +1,37 @@
# Мост к склейке: человек, `person_id`, `user_id`
Резолюция развилки [«Мост к склейке: user_id и двухкуковые
пары»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/73).
Причинная модель: план состава порождает **человека**; ему принадлежат одна
или две куки, каждая наблюдается в кликстриме как отдельный посетитель. Визит
может оформить заказ — тогда заказная сторона представляет того же человека
как **пользователя магазина**. Это не модель аккаунта: регистрации нет, люди
без визитов не порождаются, внутреннее знание наружу не выдаётся.
План владеет устойчивой личностью и отношением «кука принадлежит человеку».
Минимальная форма — непрозрачный внутренний `person_id`, выровненный по кукам:
у двух кук пары он одинаков. Заказ выводит то же значение под родным именем
`user_id` (`UInt64`); в кликстрим ни `person_id`, ни `user_id` не попадает —
анонимность формата держится формой, а не забывчивостью сериализатора.
`person_id` — последний обычный бросок потока случайности когорты, после уже
принятых свойств: добавление личности не сдвигает куки, пары, возвраты и
паспорта. Для второй куки повторяется ID её человека. Раздельные прогоны
ничего не хранят и не согласуют: генератор событий и команда слепка заново
спрашивают один план и получают тот же `person_id`.
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
отдельные прогоны одного мира дают то же соответствие; принятый мир
воспроизводит эффект склейки без случайных ложных объединений — конкретные
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
канонический мир по внешнему результату.
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
состояние между прогонами без нового внешнего эффекта; *первая кука как ID
человека* — магазин оказался бы замаскированным продолжением трекера;
*структурная координата, хеш или глобальная последовательность* — больше
механики при тех же данных; *полная модель аккаунтов* — менти её не наблюдает.
+244
View File
@@ -0,0 +1,244 @@
# Приём заказов из Kafka в ODS
Учебный результат: менти различает версию бизнес-сущности, наблюдение источника
и запуск загрузки, а затем читает физические версии через явную поверхность
текущего состояния.
## Проблема
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
отдельными сообщениями и не сообщает потребителю, где слепок закончился.
Прямое чтение Kafka Engine возвращает одну порцию без признака полноты.
Поэтому `snapshot_date` не может служить границей публикации: `REPLACE
PARTITION` могла бы заменить партицию дня неполным набором строк.
Одновременно типизированный ODS не должен принимать правдоподобные значения по
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
## Цели
- сохранить пакетный забор как контраст потоковому приёму событий;
- один раз принять байты в STG и независимо разложить строки на годные и брак;
- хранить в ODS типизированные версии заказов, не публикуя слепок заменой
партиции;
- дать следующим слоям один корректный способ прочитать текущее состояние;
- оставить код проверки коротким и ограничить его контрактом провода.
## Не входит
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
- удаление заказа по отсутствию в следующем слепке;
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
## Поток данных
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
Запрос забора лежит в [`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
пишет годные строки в `ods.order`, второй — брак в `ods.order_errors`.
Транзакции между запросами нет. При частичном сбое Airflow повторяет весь
`task_id`; одинаковые исходные строки и служебные метки не вычисляются заново.
Обе вставки лежат в одном самодостаточном файле
[`order_parse.sql`](../../../sql/ods/order_parse.sql). Airflow сначала
шаблонизирует файл, затем делит получившийся SQL на выражения и исполняет их по
порядку в одной задаче `parse_batch`.
Условия запросов взаимодополняющие: один общий предикат определяет годную
строку, а ветвь брака использует его буквальное отрицание. Все функции предиката
возвращают результат без исключения, а сам предикат всегда заканчивается в
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
этого не нужен. Предикат объявлен локальным макросом Jinja внутри
`order_parse.sql` и разворачивается в оба выражения. Макрос убирает
дублирование, но не вводит отдельный фрагмент, который нельзя исполнить
самостоятельно.
## Граница строгого приёма
Единица решения — одна строка `stg.orders_raw`. Корень должен быть
JSON-объектом с точным набором ключей: `order_id`, `user_id`, `status`,
`created_at`, `updated_at`, `items_total`, `discount`, `delivery`, `total`,
`items`, `snapshot_date`.
Скалярные поля проверяются по типу JSON. Деньги дополнительно обязаны быть
строками неотрицательной суммы с ровно двумя знаками после точки — минус в эту
форму не входит; времена — строками RFC 3339 в UTC с обязательными
миллисекундами; дата слепка — строкой `YYYY-MM-DD`. `items` проверяется только
как JSON-массив. Каноническая форма и основания выбора зафиксированы в
[исследовании формата](../../research/2026-08-16-order-snapshot-wire-format.md).
Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять
скалярных значений непосредственно образуют типизированную строку заказа. У
события из 47 полей проверяются только пять опорных; переносить то сокращение на
малый контракт заказа нет причины. Граница строгости заканчивается на форме
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
Контракт источника задаёт частоту: **одной дате соответствует один слепок**. Это
свойство всего слепка, поэтому предикат одной строки его не проверяет. Вторая
выгрузка того же дня даст ничью по `snapshot_date`, и выбор строки потеряет
однозначность. Модельный источник выполняет условие; в настоящей интеграции
его фиксируют в договоре с источником
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
Класс брака выбирается первым совпадением:
1. `not_an_object`;
2. `keyset_mismatch`;
3. `field_invalid`.
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
без постоянной детализации предиката.
## Роль ODS
`ods.order_rep` хранит физически принятые версии в
`ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
день неизменного `created_at`. `ods.order_dist` шардирует по
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
`FINAL` даёт корректный результат через распределённую таблицу.
Четыре координаты отвечают на разные вопросы:
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
- `updated_at` — когда строку последний раз меняли в источнике;
- `_load_id` — какой запуск Airflow принял строку;
- `_load_ts` — когда строка приехала в хранилище.
Версию выбирает `snapshot_date`: выживает строка с максимальной датой слепка
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому
`snapshot_date` показывает последнее наблюдение заказа. По строкам последней
даты слепка DDS определяет окно пересборки. `_load_id` и `_load_ts` принадлежат
той физической строке, которую сохранило слияние, и описывают её доставку.
Схема версий стоит на дисциплине источника: слепок с поздней датой не
показывает состояния старше. Модельный мир её держит — состояние читается на
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
больше не показывает, и там помогает только сверка самих полей, в терминах
dbt snapshots — стратегия `check` вместо `timestamp`.
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
может быть простым представлением над `_dist FINAL`; способ выбора можно
заменить, не меняя потребителей.
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
какие сущности, связи и производные признаки ему нужны. Судьба служебных
колонок выше ODS решена вместе с моделью DDS: чужие ярлыки туда не переезжают
([модель заказа](../dds/order.md)).
## Одно чтение Kafka
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов.
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
или публикация неполного дня.
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
## Отклонённые варианты
- Партиционная идемпотентность — `REPLACE PARTITION snapshot_date` или
`ReplacingMergeTree` по `(snapshot_date, order_id)`: у потребителя нет
признака полноты партиции, а дата наблюдения становится частью ключа
сущности.
- Обычный `MergeTree` в ODS с дедупликацией только в DDS: навсегда сохраняет
технические повторы там, где семантика версии уже известна.
- Маркер, опись, чтение до пустоты или конечные офсеты: добавляют протокол ради
объёма, который с большим запасом помещается в одну порцию.
- Два `task_id` или материализованный классификатор: дробят один короткий
переход слоя, не добавляя транзакционности.
- Представление текущего состояния в DDS и готовая схема `dds.order` в этой
задаче: перекладывают механику ODS на следующий слой и преждевременно задают
модель данных.
## Риски и проверка
- На малой управляемой порции дать по одной строке каждого класса брака и две
годные версии одного `order_id`. Две цели должны сохранить все непустые
сообщения, а `ods.order_v` — вернуть новую версию независимо от фонового
слияния.
- Повторить переход с тем же `_load_id`: строка в `ods.order_v` и её `_load_ts`
не должны измениться; версии одного заказа должны остаться на одном шарде и
в одной партиции. Таблица ошибок может снова записать тот же брак: совпавшие
`_load_id` и Kafka-координаты показывают повтор задачи.
## Что проверено
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение сырья на годные
строки и брак полное и непересекающееся; две версии одного заказа легли в одну
партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от фонового
слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
опирается, — [документ хранилища](../storage.md), «Что проверено».
После исправления
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки
слепка дня 7 приняты, `ods.order_errors` осталась пустой. У заказа
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь
`5490.00 − 823.50 + 299.00 = 4965.50`.
При исполнении #124 21 августа 2026 года адресная пересборка мира прошла через
`SQLExecuteQueryOperator`: `pull_batch` и `parse_batch` завершились успешно. В
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
пользователя `etl` для забора в STG, записи годных строк и записи брака.
Дисциплина источника замерена 22 августа 2026 года при исполнении #137.
Во всех 3034 парах строк с равным `updated_at` деловые поля совпали
побайтово, а по слепкам метка ни разу не убывала.
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним
слепком заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из
сырья. В обеих сверках расхождений нет.
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR
0008](../../adr/0008-order-ingestion.md), раздел «Что проверено».
MCP Context7 в сессии проектирования был недоступен. На локальном ClickHouse
`26.3.17.56` проверено, что прямой `SELECT` Kafka Engine завершается после
одной порции, а `FINAL` через `Distributed` исполняется на таблицах шардов.
Поэтому версии одного `order_id` направляются на один шард. Фоновое схлопывание
`ReplacingMergeTree` и необходимость точного чтения сверены с
[официальной документацией](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree),
поведение чтения — с исходниками той же версии
[`StorageKafka.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/StorageKafka.cpp) и
[`KafkaSource.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/KafkaSource.cpp).
## Связанные решения
- [ADR 0008](../../adr/0008-order-ingestion.md) сохраняет выбор пакетного
забора, `RawBLOB`, одного чтеца и одной партиции топика.
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) заменяет публикацию
слепка версионным ODS.
+30
View File
@@ -0,0 +1,30 @@
# Что хранит опись
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
и [«Места заказов в стартовом
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
Словарь описи — растяжка-хеш против опоры-счётчика — задан «Чем меряется
генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
термином «Опись мира» в [CONTEXT.md](../../../CONTEXT.md).
> Опись хранит только то, чего движение мира не меняет.
> Хеш кладём всегда, счётчик — только когда назван его читатель.
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
байты») опись начинает сторожить.
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит
число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных
днях закрыты N − 7 (день d закрывается слепком d + 6, а последний
отправленный слепок несёт день N − 2). Их читатель — техническая
проверка `classes_vs_inventory` из DQ-звена этапа 4.
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
не выходит; генератор сторожит хеш, транспорт — счёт событий.
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
приёма заказов — число заведётся вместе с ней.
+116
View File
@@ -0,0 +1,116 @@
# Заказ и его слепок
Резолюция развилки [«Генератор слепков: где живёт и чем связан с
событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71).
## Откуда берётся заказ
Заказ и событие `purchase` — не два порождения, а две проекции одного факта
мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной
дня-функции; заказная половина берёт заказы дня готовой структурой — вторым
выходом `commerce`, — навешивает на них жизнь заказа и собирает слепок. В
подпоток `COMMERCE` не добавляется ни одного нового броска: мир не сдвигается,
согласованность двух источников не удерживается, а получается по построению.
Следствия, которые уже решены соседями и здесь только связываются:
- у всякого заказа изначально ровно одно событие `purchase`; заказ без события
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
выбрасыванием события после присвоения номера
([классы расхождений](fate.md));
- обе проекции используют один номер заказа: `order_id` = клиентский
`purchaseID`;
читаемый номер «день и порядковый номер покупки» описан в
[ADR 0017](../../adr/0017-order-numbering.md); нумеруются все
покупки, дошедшие до потока дня, — до всяких потерь;
- скидка заказа выводится из промокода события по таблице «код → скидка» —
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
В модели строка заказа в базе источника создаётся синхронно с покупкой,
поэтому день рождения заказа и день создания строки совпадают.
**Деньги заказа** складываются целыми копейками: `items_total` — сумма
позиций, посчитанная торговой половиной, то же число, что уехало клиентским
`purchaseRevenue`, но у заказа с вычеркнутой позицией он меньше на её полную
стоимость ([судьба заказа](fate.md)); `discount` — процент промокода от
оставшегося `items_total`, округлённый вниз: склад сначала определяет, какие
позиции есть в заказе, затем бэкенд считает скидку от итоговой корзины;
`delivery` — бросок заказной стороны по таблице целых весов, единственные
деньги заказа, которых нет ни в одном событии; `total` = `items_total` −
`discount` + `delivery`. Скидка не превышает `items_total`, поэтому итог
неотрицателен по построению. Отсюда и правило витрин «деньги считаем по
бэкенду»: про скидку и доставку клиент не знает вовсе.
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
это разные источники; *независимая модель бэкенда* (заказ первичен, событие —
эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание
всего генератора; *слепок собирает SQL стенда из событий* — второй источник
исчезает вместе с уроком «две версии правды».
## Слепок и его доставка
**Запуск.** Третья команда того же пакета — `snapshot --day D [--days N]`,
свой приёмник, топик `orders`. Два источника — два запуска: трекер и бэкенд
видны глазами как два производителя, каждый со своим топиком. Один прогон с
двумя выходами отклонён: экономии он не даёт (со сдвигом отправки окно слепка
и сыгранный день не пересекаются вовсе), а правило «приёмник выбирается тем,
что для него назвали» ломает. Отклонены также: *отдельный пакет и образ* —
библиотека на двоих ради одной команды; *генератор пишет слепок файлом, в
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
топиком `hits`* — убивает два режима приёма.
**Сборка окна.** Слепок дня D берет заказы, рожденные в дни D−6…D, а несет из
них те, что уже попали в выгрузку: механика опоздания живет в
[судьбе заказа](fate.md). Окно собирается переигровкой этих семи дней: заказы
дня — производная всей воронки дня, дешёвого пути к ним нет. Цена — семь
проигрышей дня (~14 с) на слепок; у начала оси окно усекается само. Отклонено:
*кэш заказов на томе* — состояние между прогонами; *окно держит хранилище* —
топик перестаёт нести слепок; *K = 1* — это страховочный срез 1 мастер-спеки,
он в резерве.
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
бэкенда за вчера, как в бою. Содержимое слепка — чистая функция (зерно, D), от
момента отправки не зависит. Следствия:
- живой день перестаёт быть особым случаем: своего дага у него нет, слепок
живого дня отправит следующий прогон;
- пропущенный день лечится окном: слепок переснимается и даёт те же байты,
отдельного механизма самовосстановления нет;
- покупки текущего дня в сверке всегда `awaiting_order` — сюжет «вчера не
сходилось, сегодня сошлось», ради которого мастер-спека этот класс завела;
- цена — один лишний проигрыш дня на прогон (окно и сыгранный день не
пересекаются, проигрышей всегда восемь).
На старте оси дня −1 нет, поэтому прогон дня 0 не отправляет ничего; первый
слепок — дня 0 — уезжает прогоном дня 1 ([исследование
формата](../../research/2026-08-16-order-snapshot-wire-format.md)).
**Случайность.** Судьбу заказов бросает свой подпоток, ветвящийся по дню
рождения заказа: слепок несёт семь дней рождения сразу, и судьбу каждого
заказа обязан читать из его собственного дня. Вся судьба решается при
рождении, поэтому слепок любого дня — чтение готовой судьбы, а не накопление
состояния. Отклонено: *дописывать броски в конец `COMMERCE`* — правка заказа
и правка торгового поведения стали бы одним рычагом; *бросать состояние в
подпотоке дня слепка* — траектория заказа зависела бы от того, какие слепки
снимали.
## Запись на проводе
Контракт провода — на заказ один JSON-документ: деньги строками с двумя
знаками, времена RFC 3339 в UTC с миллисекундами, `items` обычным массивом —
целиком описан мастер-спекой (раздел 2); основания, отклонённые варианты и
проверка разбора — в [исследовании
формата](../../research/2026-08-16-order-snapshot-wire-format.md) (резолюция
развилки
[«Форма записи слепка на проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81)).
Отрицательные копейки канонический сериализатор отвергает через `ValueError`
до сборки записи: [контракт строгого
приёма](ingestion.md#граница-строгого-приёма) их не допускает, а появление
такого значения означает ошибку арифметики источника.
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
его даром, а хешу слепка в описи нужен именно названный порядок.
+35
View File
@@ -0,0 +1,35 @@
# Заказы в стартовом мире
Резолюция развилки [«Места заказов в стартовом
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
Стартовый мир — не полный мир, а мир, остановленный на границе суток 7|8.
Генератор играет два источника с разными темпами — поток Метрики и ночную
выгрузку магазина; разные темпы дают всё остальное.
Даг `world_initialize` играет восемь дней одним прогоном
`batch --day 0 --days 8`; слепки отправляет второй запуск — команда `snapshot`
того же диапазона (два источника — два запуска,
[слепок и его доставка](snapshot.md)). Со сдвигом отправки уезжают **слепки
дней 0…6**, затем даг `orders_ingest` принимает их в хранилище. Слепок дня 7
снят на границе суток и уедет первым же ходом мира. Особого режима у стартового
мира нет: правило отправки живёт в одном месте — в проигрывателе; генератору
это решение не стоит ничего.
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
**График выручки заваливается к правому краю** и дозаполняется, пока мир едет.
Это не издержка стенда, а главный наблюдаемый эффект второго источника: ночная
выгрузка отстаёт, свежие дни предварительны. На свежем стенде лаба сверки
видит целый день `awaiting_order` — норма, а не поломка; как это назвать
менти — за витринами этапа 4.
Цена принята с открытыми глазами: у части пар стартового мира поздний
назначенный заказ падает на день 7, и до первого хода мира этих пар в
`dds.identity_map` нет. Опись пар не считает
([что хранит опись](inventory.md)), поэтому красной проверки из этого не
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* —
число верно ровно до первого хода мира; *отменить сдвиг отправки* —
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир
перестал бы быть чистой функцией зерна.
+871
View File
@@ -0,0 +1,871 @@
# Хранилище: слои и конвенции
Документ описывает сторону ClickHouse: как называются объекты, какие служебные
колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из
каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное
событие с таблицей ошибок, поверхность актуального состояния и три матвью.
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
Тот же этап принёс первый справочник — словарь товаров из общего с генератором
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат
публичным договором события `dds.event_v` над точным состоянием ODS, а следом
получил две физические модели слоя — заказ и сессию: их собирает заменой
партиций даг `dds_transform`. Сессия среди них особая: в потоке такой сущности
нет, стенд режет её из событий сам.
Дальше по тексту устройство описано так, как оно проектируется; построенное от
заложенного отличает карта таблиц в конце.
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
события — в [описании выгрузки](../formats/clickstream-event.md). Хранилище
строится по описанию выгрузки, как в бою строится по документации источника.
Целевая картина всего стенда — [спека «Боевой реализм стенда
(v2)»](../specs/2026-07-30-stand-v2-realism.md).
## Имена объектов
Имя объекта заканчивается тем, что это за объект:
| Суффикс | Что это |
|---|---|
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` |
| `_dist` | `Distributed` поверх одноимённой локальной |
| `_kafka` | таблица на движке `Kafka` |
| `_file` | таблица на движке `File` — чтец файла |
| `_mv` | материализованное представление |
| `_v` | обычное представление, публичная поверхность чтения |
Суффикс носит каждый физический объект. Голого имени у таблицы не существует:
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
именем в документах и разговоре понимается сущность, у которой этих объектов
несколько. Единственное исключение — словари: у них воплощение одно, шардировать
нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
словарь.
Распространённая конвенция, где голое имя означает локальную таблицу, а
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы —
только по `Distributed`» такую тишину переживает плохо.
Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются
хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`:
порядок выдачи `SHOW TABLES` документация не оговаривает.
Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя
приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем
топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях
имя приходит от предметной области и стоит в единственном числе: `event`,
`order`, `session`. Граница между «как привезли» и «что это такое»
проходит по STG, и имена её показывают.
В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы
и витрины называют только представление; `_rep` и `_dist` служат загрузке,
диагностике и обслуживанию. Представление перечисляет колонки явно, даже если
его запрос — простая проекция над `_dist`: договор не выдаёт устройство
кластера и случайно добавленные в физическую таблицу колонки. У события
физической копии в DDS нет, но правило то же: `dds.event_v` — его публичный
договор.
Право доступа здесь намеренно шире договора. Роль `bi_reader` может читать
любые объекты ODS, DDS и DM, но физические объекты от этого не становятся
публичной поверхностью. Конвенцию держат явный список колонок, документация и
ревью, а не запрет на физические таблицы.
Широкое право принято в [ADR 0007](../adr/0007-clickhouse-access.md): узкая
граница поверх `Distributed` потребовала бы отдельной механики. Механизм
ClickHouse и отклонённые варианты записаны в
[исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
## Раскладка по шардам
Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания —
операций с партициями, ручной переобработки. Правило не про удобство: при записи
через распределённую таблицу раскладку определяет ключ шардирования, то есть
свойство данных, а при записи в локальную — то, какая нода случайно выполняла
код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами
(видно в колонке `consumer_host`), а куда легли данные — нет.
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
выбран против перекоса: структурированный числовой идентификатор распределяется
по остатку от деления неравномерно. У сырья выбора нет — строку иначе не
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
шард.
Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и
разобранное из неё событие почти всегда оказываются на разных шардах.
Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны —
сверять слои можно только через `_dist`.
Разные ключи у слоёв ставят капкан на вставке `INSERT INTO цель_dist SELECT …
FROM источник_dist`. Заводское значение `parallel_distributed_insert_select` —
2: если обе таблицы на одном кластере, вставка исполняется локально на каждом
шарде, минуя ключ шардирования цели. Строки наследуют раскладку источника, и
ошибки при этом нет — расхождение видно только пересчётом ключей по
`hostName()`. Так половина заказов и легла на оба шарда, где
`ReplacingMergeTree` через границу шарда дубли не схлопывает (замер — «Что
проверено», #137). Лекарство — явное `parallel_distributed_insert_select = 0`
в `SETTINGS` такой вставки: раскладку делает инициатор, по ключу цели
(`sql/ods/order_parse.sql`). Капкан ждёт и сборки DDS: они тоже будут читать
`_dist` и писать в `_dist` с другим ключом.
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё
прочее — через `GLOBAL`. Значит `dds.session_dist` и `dds.identity_map_dist`
шардируются по `cityHash64(client_id)`. Объекты DDS с зерном заказа сохраняют
ко-локацию по `cityHash64(order_id)`; ключи остальных частей модели и витрин
появляются вместе с ними.
Замена партиций не используется для `ods.order`: версии прошлых дней
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким
объектом, а не переносить на 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).
## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
`_topic`, `_partition`, `_offset`, `_timestamp` у Kafka, `_shard_num` у
`Distributed` и прочие. Если положить на диск колонку с таким же именем, в
матвью перестанет читаться, что дано движком, а что положено нами, — а это
ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они
ложатся под именами `kafka_topic`, `kafka_partition`, `kafka_offset`,
`kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные
колонки его не несут, а после записи в `Distributed` он уже невосстановим.
Типы у них такие: `kafka_topic` и `consumer_host` — `LowCardinality(String)`,
значений мало и они повторяются; `kafka_partition` и `kafka_offset` — `UInt64`.
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы
либо падение приёма на первом сообщении, либо тихие нули за 1970 год.
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, —
колонка молча отвечала бы на другой вопрос.
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
не проверяет, поэтому там оказываются и целые сообщения, и мусор. События ниже
разбирают матвью ODS ([ADR 0005](../adr/0005-event-ingestion.md)), заказы —
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
0010](../adr/0010-order-versions-in-ods.md)).
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
которого он заведён: повтор доставки в сырье обязан быть виден.
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
источника; `_load_ts` только показывает, когда конкретная строка приехала.
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
`DateTime64(3, 'UTC')`; ClickHouse их не создаёт и добавляет рядом собственную
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
Airflow. Переделать разобранное руками можно вставкой из сырья с фильтром по
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
`_load_id`; дневные партиции ODS он не заменяет.
Имя согласовано с каноном служебных полей соседнего учебного стенда на
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
технических колонок — распространённая запись,
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
Общего идентификатора пачки загрузки (`_load_id`) нет. У потока событий нет ни
пачки, ни `run_id`, и колонка была бы пустой формальностью. У заказов читатель
назван: `_load_id` равен `run_id` Airflow и переносится из STG в годную строку
ODS и в таблицу ошибок. Нужен ли он выше ODS, решается вместе с моделью DDS.
## Актуальные события
`ods.event_rep` хранит физически принятые версии события в
`ReplacingMergeTree(_load_ts)`, а `ods.event_dist` служит для записи и
диагностики доставки. Обычное чтение этой пары может показать несколько строк
одного `WatchID`, пока фоновые слияния их не схлопнули.
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное
состояние читают через него, а не повторяют `FINAL` в каждом запросе.
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
числе snake_case-имена и расшифровку кодов.
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
исключение: источник объявляет `Array(Float64)`, а материализованное
представление ODS разбирает числа сырого JSON сразу в
`Array(Decimal(18, 2))`. DDS наследует этот тип без нового преобразования.
Прямой разбор подтвержден документацией ClickHouse через Context7 и запросом
к ClickHouse 26.3; сравнение с сырьем подтвердило: копейки всех покупок целы.
## Версии заказов
`ods.order_rep` хранит принятые версии в
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
`toDate(created_at)`. `ods.order_dist` шардирует по
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
выбрал одного победителя.
Физическая пара нужна для загрузки и диагностики. Обычное чтение показывает
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
`ods.order_v` служит поверхностью точного текущего состояния для следующих
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
DDS. `snapshot_date` в нём одновременно дата наблюдения и колонка версии. Она
не доказывает полноту принятой порции, поэтому ODS не публикует партиции по
дате слепка. Модель версионного ODS записана в
[ADR 0010](../adr/0010-order-versions-in-ods.md), выбор колонки версии описан в
[ADR 0014](../adr/0014-order-version-snapshot-date.md), пакетный приём описан в
[спецификации приёма заказов](orders/ingestion.md). Контракт следующего слоя
задан в [модели заказа в DDS](dds/order.md).
## Часовые пояса
Пояс — линза, а не свойство значения. `DateTime` хранит одно число, секунды от
начала эпохи; пояс решает лишь, какие часы по этому числу покажут время и в
какие сутки оно попадёт.
**Линза называется явно — в типе колонки либо в вызове функции.** Третий
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
где линза что-то решает — в объявлении хранимой колонки и в выражении,
считающем дату, — его не остаётся. Прописать этот пояс своей рукой —
`<timezone>` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью
с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах
и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
поменяв его. Правило стоит на источнике пояса, а не на функции:
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя
пишется тогда, когда нужна другая линза, чем у колонки:
`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика».
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
значит `DateTime('UTC')`; служебные метки `_load_ts` и `kafka_timestamp`
абсолютны тоже — `DateTime64(3, 'UTC')`. DDS и витрины обслуживают человека с
дашбордом, поэтому время там лежит местным, в поясе счётчика (`Europe/Samara`,
UTC+4), и пересчёт идёт один раз при наполнении слоя: автор отчёта пояса не
пишет, он берёт готовую колонку. `Date` хранит только номер дня — чьи это
сутки, в нём не записано. Поэтому время по `EventDate` считают, назвав пояс
руками; промах виден сразу: часы суток уезжают за границы 0–23.
Объявить местным и ODS — `DateTime('Europe/Samara')` — соблазнительно: байты те
же, меняется одна линза, и `toDate(UTCEventTime)` начинает совпадать с
`EventDate` всегда. Отвергнуто потому, что колонка зовётся `UTCEventTime` и в
настоящей выгрузке Метрики она в UTC, а стёртое расхождение — тот самый урок,
ради которого мир сделан с поясом счётчика.
**Линза выбирается дважды, и второй раз упустить легко.** На чтении — показать
метку или свести её к дате. На записи — превратить строку в число: суффикс `Z`
на проводе зоны не даёт, маска разбора съедает его буквой, и
`parseDateTimeOrNull` без третьего аргумента трактует показания часов по поясу
сессии, а тот по умолчанию серверный. Тип колонки тут не помогает: разбор
отдаёт готовое число, и колонка кладёт его как есть — пояс приёмника решал бы
судьбу строки, а не числа. Механика и выбор функции — [ADR
0005](../adr/0005-event-ingestion.md).
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
генератором и лежит колонкой, так что суточные срезы группируются по ней и
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md).
## Путь реплицированных таблиц в keeper
Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути
обязательна: без неё одноимённые таблицы разных слоёв получат один и тот же узел
в keeper и подерутся. Макрос `{uuid}` не используем, хотя он тоже развёл бы
пути: он завязан на движок базы `Atomic` и делает путь нечитаемым, а на учебном
стенде возможность открыть `system.zookeeper` и увидеть осмысленный путь — сама
по себе половина урока про то, чем занят keeper.
## Приём: поток и его свойства
Раздел — про события. У заказов приём устроен иначе: слепок забирает по команде
даг `orders_ingest`, а не подписанная навсегда матвью — [ADR
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
заказов](orders/ingestion.md).
Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и
таблица ошибок ODS.
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
`ON CLUSTER` и макросов в имени не содержит. Разные группы дали бы каждой ноде
полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя
кластера в `ON CLUSTER` и в движке `Distributed` — `clickstream_cluster`, оно
задано в `infra/clickhouse/config.d/cluster.xml`.
**Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.**
У матвью две привязки: источник, на вставку в который она срабатывает, и цель,
куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище;
потребитель слоя цепляется к лицу. Практически это значит, что разбор идёт на
той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по
шардам.
**Вставка фоновая, и окно потери мы принимаем.** Вставка в распределённую
таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka
коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик
уже считает сообщение прочитанным, хотя на шарде его ещё нет: умри нода в этом
промежутке — сообщения не перечитаются.
Закрывает окно настройка `distributed_foreground_insert = 1`, и на ETL-вставках
Airflow она стоит — там это обычный `SETTINGS` у запроса. На пути приёма её нет,
и по трём причинам. Вставку выполняет фоновый поток Kafka-движка, своего запроса
у него не бывает, так что настройка уровня запроса доехала бы только профилем
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
как при фоновом первая нода продолжает принимать и копит спул для соседа.
Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё,
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой.
Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и
запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято
осознанно — подробности в разделе «Что проверено».
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
сырому слою не относится.
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
Удвоение сырья проходит мимо неё только потому, что при повторном чтении
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
отсутствии механизма.
**Матвью разбора две, и их условия обязаны делить поток без зазора и без
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным
отрицанием первого, а сам предикат NULL не возвращает ни в одной своей части, —
иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`, ни
`NOT условие`. Обнуляемый разбор в предикате поэтому есть, но заканчивается
`IS NOT NULL`, а сравнения дают 0 или 1. Функции предиката не должны и бросать
исключений: упавшая матвью
роняет вставку и останавливает потребление до починки
([ADR 0005](../adr/0005-event-ingestion.md)).
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырья
срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события +
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
смотреть на оповещения
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой
опыт не становится, и почему — в [карте
проверок](testing.md), раздел «Интеграционная проверка постоянной целью не
становится». Счёт актуальных событий идёт через `ods.event_v`, которое скрывает
`FINAL`: голый `count()` по `ReplacingMergeTree` зависит от того, сколько
мержей успело пройти, и спека это прямо запрещает (раздел 6).
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
против [описи мира](../../data/world-inventory.json), лежащей в git
(`make check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в
этом вся разница: опись описывает восемь дней стартового мира, счёт обрамлён
их датами, повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не
помеха — ODS хранит всё. Обе стороны равенства зафиксированы: одна кодом
генератора, другая файлом в git. У межслойной сверки такой опоры нет ни с
одной стороны.
## Срок жизни сырья
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты,
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL
значение проставлено явно, чтобы поведение не зависело от умолчания версии.
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
снимается целиком, только когда в нём истекли все строки, а в партиции модельного
дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста,
самая свежая строка удержит весь кусок, и данные переживут срок неограниченно.
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
возраста — но не более чем на сутки, потому что партицию закрывает календарный
день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно
трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что
«уходит само» здесь обещано, а «уходит вовремя» — нет.
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё
режется своими координатами: и разбор полётов, и переобработка фильтруют по
`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
эталонные дни прямо на входе.
В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе.
Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не
ноль.
## Таблица ошибок
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у
брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у
строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и
сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним,
разбираться к моменту разбирательства будет уже нечем. Снятие — целыми кусками
(`ttl_only_drop_parts`), как у сырья и по той же причине: строки в партиции дня
загрузки разного возраста не более чем на сутки, и куску незачем переживать
срок из-за самой свежей строки.
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
качества не на что опереться. Сами классы, их порядок и довод, почему порядок
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition,
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
доставки.
`ods.order_errors` держит тот же диагностический минимум и `_load_id`
запуска. У заказов три класса по приоритету: `not_an_object`,
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
разрастается до имени отдельного поля. Точная граница приёма — в
[спецификации заказов](orders/ingestion.md).
## Справочники
Справочные данные живут в базе `dic` — вне цепочки STG → ODS → DDS → DM. В
хранилище их никто не производит: содержимое приходит из файла репозитория, а
читают его несколько слоёв сразу. Отсюда правило принадлежности: читать зону
вправе любой слой, писать — никто. Почему зона, а не прописка в слое модели, и
почему словарь читает подложку, а не файл, — [ADR
0012](../adr/0012-dictionary-home.md).
Первый и пока единственный справочник — каталог товаров: подложка
`dic.products_file` над CSV репозитория и словарь `dic.products` поверх неё.
Распределённых пар у зоны нет: обе ноды читают свой смонтированный файл и
держат свою копию словаря в памяти. Словарь обновляется сам, окном `LIFETIME`,
и потому после правки каталога ноды какое-то время отвечают по-разному.
Разогнать его раньше срока — `SYSTEM RELOAD DICTIONARY ON CLUSTER`; это
административная операция, роль `etl` права на неё не получает.
Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт
`Decimal(18, 2)`, `productPrice` события — целые рубли. Поэтому точную сумму
заказа берут из `purchaseRevenue`, а не складывают округленные цены товаров
([описание выгрузки](../formats/clickstream-event.md)).
Устройство обоих объектов — почему такой движок, такая форма пути, такой
пользователь и такое окно — расписано в
[`sql/ddl/05-dic-catalog.sql`](../../sql/ddl/05-dic-catalog.sql).
## Раскладка SQL
Исполняемые дагами запросы лежат по правилу
`sql/<слой>/<объект>_<роль>.sql`. Слой — слой цели запроса: забор заказов в
сырьё живёт в `sql/stg/orders_raw_load.sql`, разбор — в
`sql/ods/order_parse.sql`. Даг называет файлы, передаёт параметры и задаёт
порядок выполнения, но не хранит текст преобразований. Два выражения разбора
делят локальный макрос Jinja в том же файле; подключаемого SQL-фрагмента между
ними нет.
SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода
обработчик DAG читает список DDL-файлов, загружает содержимое каждого
названного SQL-файла и кладёт его в сериализованный граф. При исполнении Airflow
разворачивает в этом тексте Jinja; параметры ClickHouse передаются отдельно и
не подставляются строкой. Каталог `sql/` смонтирован во все службы Airflow
только для чтения.
### DDL
Файлы лежат в `sql/ddl/` и применяются по порядку имён: источник и цель
создаются до представления или матвью, которые их используют.
| Файл | Что в нём |
|---|---|
| `00-databases.sql` | базы слоёв и зона справочников |
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
| `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
| `50-dds-views.sql` | публичные договоры сущностей DDS |
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
| `60-dm-views.sql` | публичные договоры витрин DM |
Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
правила порядка слоёв к ней не применяются.
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому
что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся
матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает
читать топик, когда к нему привязывают первую матвью; создай её раньше
разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS
никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден,
поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse
снесены, а данные Kafka целы, — то есть на обычной отладке.
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
`lifecycle`: один файл — одна последовательная задача
`SQLExecuteQueryOperator`, а выражения внутри разделяет сам оператор. Всё
`ON CLUSTER`, кроме чтеца заказов — он объявлен только на этой ноде. Перед DDL
даг создаёт `hits` с двумя партициями и `orders` с одной
([ADR 0008](../adr/0008-order-ingestion.md)). Этот порядок не даёт `hits`
автоматически создаться с одной партицией. Брокер автосоздание разрешает, а
потребитель librdkafka внутри ClickHouse его не просит. Оба конца измерены;
результаты приведены в разделе «Что проверено». Успех дага означает больше,
чем применённый DDL: оба стартовых
источника приняты, а позиция мира установлена последней задачей. Полный граф и
состояния отказа описаны в [спеке жизненного цикла](../specs/2026-08-21-world-lifecycle.md).
Повторный `make up` поверх готового мира проходит зелёным без изменений:
`world_initialize` ветвится раньше DDL. Поэтому изменённый объект обычным
подъёмом не применяется. В разработке меняют или пересоздают только затронутый
объект, партицию либо базу слоя. Если нужен целиком канонический мир,
`make rebuild-storage` запускает `world_recreate`: тот удаляет прикладные базы
`stg`, `dm`, `dds`, `ods`, `dic`, позиции групп потребителей и прикладные топики,
затем вызывает `world_initialize`. Службы и их тома остаются на месте, а позиция
возвращается к концу стартовых дней. Полный `make clean` для изменения DDL не
нужен. Когда какая область очистки уместна перед проверкой, задаёт
[карта проверок](testing.md).
## Карта таблиц
Ниже — объекты, уже построенные этапами 2–4; всё перечисленное лежит в
`sql/ddl/`.
| База | Объект | Что это |
|---|---|---|
| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
| `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
| `stg` | `stg.hits_raw_mv` | наполняет сырьё из чтеца |
| `stg` | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью |
| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие |
| `ods` | `ods.event_v` | актуальная версия события с полями источника |
| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |
| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки |
| `ods` | `ods.order_rep` / `_dist` | типизированные версии заказа |
| `ods` | `ods.order_v` | текущая версия заказа на языке источника |
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
| `dds` | `dds.event_v` | событие на языке бизнес-модели без физической копии |
| `dds` | `dds.order_rep` / `_dist` | заказ в текущем состоянии, партиция — день заказа |
| `dds` | `dds.order_stage_rep` / `_dist` | двойник заказа: в нём день собирается перед заменой партиции |
| `dds` | `dds.order_v` | публичный договор заказа |
| `dds` | `dds.session_rep` / `_dist` | визит, нарезанный слоем; партиция — день сессии |
| `dds` | `dds.session_stage_rep` / `_dist` | двойник сессии: в нём день собирается перед заменой партиции |
| `dds` | `dds.session_v` | публичный договор сессии |
| `dds` | `dds.identity_map_rep` / `_dist` | известная пара куки с пользователем; таблицы полного пересчёта |
| `dds` | `dds.identity_map_v` | точный публичный договор карты идентичностей с `FINAL` |
| `dm` | `dm.revenue_daily_rep` / `_dist` | дневная выручка по категориям |
| `dm` | `dm.revenue_daily_stage_rep` / `_dist` | таблица-двойник, донор дневных партиций выручки |
| `dm` | `dm.revenue_daily_v` | публичный договор дневной выручки |
| `dm` | `dm.purchase_vs_orders_rep` / `_dist` | сверка покупок с заказами, партиция — день заказа |
| `dm` | `dm.purchase_vs_orders_stage_rep` / `_dist` | донор дневных партиций сверки |
| `dm` | `dm.purchase_vs_orders_v` | публичный договор сверки с `NULL` для отсутствующей стороны |
| `dm` | `dm.daily_traffic_rep` / `_dist` | версии дневных счетчиков трафика |
| `dm` | `dm.daily_traffic_v` | точные текущие счетчики трафика с `FINAL` |
| `dm` | `dm.dq_summary_rep` / `_dist` | текущее качество дня по атомарным проверкам |
| `dm` | `dm.dq_summary_stage_rep` / `_dist` | общий донор дневных партиций DQ-сводки |
| `dm` | `dm.dq_summary_v` | публичная сводка качества дней |
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
| `dic` | `dic.products` | словарь товаров поверх подложки |
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два
`INSERT SELECT` одной задачи `parse_batch` из файла
`sql/ods/order_parse.sql`.
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
карта пишет полный набор пар в версионную таблицу. В DM выручка и сверка
заменяют партиции изменяемых дней, а трафик пишет полную историю новой версией.
## Что проверено
Документ на каждом шагу опирается на поведение ClickHouse, а местами и Kafka.
Поэтому утверждения о них разведены на три группы: насколько фразе можно верить,
должно быть видно из текста, а не зависеть от того, хорошо ли автор помнит
документацию. Сверка с
документацией — через MCP Context7, 5 августа 2026 года; то же разведение для
механики приёма — в [ADR 0005](../adr/0005-event-ingestion.md).
**Проверка `ods.event_v` 18 августа 2026 года.** Документация ClickHouse через
MCP Context7 подтвердила обычное представление с `FINAL` как способ скрыть
схлопывание от потребителя. На закреплённом ClickHouse 26.3 в ODS добавили
вторую физическую версию одного события: обычный счёт вырос на один, а
`ods.event_v` остался равен `ods.event_dist FINAL`. Имена и типы всех колонок
представления совпали с распределённой таблицей.
**Проверка словаря товаров 19 августа 2026 года (#95).** Context7 подтвердил
DDL-словарь с файловым источником внутри `user_files`, хешированную раскладку
для строкового ключа и отключение фонового обновления через `LIFETIME(0)`.
На закреплённом ClickHouse 26.3 цена известного `sku` после правки CSV и
`SYSTEM RELOAD DICTIONARY ON CLUSTER`
изменилась на обеих нодах; после отката и повторной команды вернулась обратно.
**Проверка зоны справочников 20 августа 2026 года (#105).** Подложка на движке
`File` перечитывает файл на каждом запросе, словарь поверх неё обновляется сам
внутри окна `LIFETIME`, а собранный с нуля стенд поднимает оба объекта и отдаёт
словарь читателям. Замеры целиком — в
[ADR 0012](../adr/0012-dictionary-home.md), раздел «Что проверено»; там же
разобрано, почему отказ в праве на движок называет не то право, которого не
хватает.
**Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и
`system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку
значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT`
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`.
На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов
лежали на обоих шардах, после явного нуля и повторного разбора того же
`_load_id` — ни одного. Вершина версий уцелела: деловые поля `ods.order_v`
сошлись с эталоном, посчитанным прямо из сырья.
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3.
Три находки касаются не заказов, а самого ClickHouse, и знать их стоит любому,
кто пишет здесь строгий разбор:
- **`toDateOrNull` календарь не проверяет.** `'2026-02-30'` он молча превращает
в `2026-03-02`, `'2026-13-01'` — в `1970-01-01`. Для строгого приёма он
поэтому не годится: нужен `parseDateTimeInJodaSyntaxOrNull(…, 'yyyy-MM-dd',
'UTC')`, который на обеих строках даёт `NULL`.
- **Маска разбора формы не держит.** `parseDateTime64InJodaSyntaxOrNull` по
маске `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'` берёт и `2026-6-3T14:21:07.123Z` — без
ведущих нулей. Форму приходится сверять отдельно, регулярным выражением;
зато календарь маска проверяет честно (30 февраля, 13-й месяц, 25-й час
дают `NULL`), как и лишние или недостающие знаки долей секунды и смещение
`+00:00` вместо `Z`.
- **У временных типов есть потолок, и он ниже, чем кажется.** `DateTime64(3)`
заканчивается на `2299-12-31`, `DateTime` — на `2106-02-07`; строки за
потолком разбор отдаёт как `NULL`. Это ловит опыт, который берёт «заведомо
далёкий» год: 2999-й не разбирается вовсе.
Про сам приём заказов снято следующее. Предикат формы провода разложил все
13 661 строку сырья, приехавшую при исполнении #93, без исключений и без
`NULL`: 13 653 годных и 8 брака, сумма сошлась с общим счётом по каждому
`_load_id`, пересечение ветвей — ноль. Все восемь строк брака — версии одного
заказа с отрицательным `total`; это дефект генератора, разобранный в
[спецификации приёма](orders/ingestion.md), «Что проверено». На управляемой
порции из пяти строк (две версии одного заказа плюс по одной строке каждого
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
через распределённую таблицу вернул одну строку — ту, что несла позднейшую
версию, — тогда как физический счёт показывал две. Повтор задачи с тем же
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
записала тот же брак второй раз, как и обещано спецификацией приёма.
Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у
запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, —
на одном и том же срезе из пяти строк две вставки дали 4 и 5. Сколько чего
легло, спрашивают у самих таблиц по `_load_id`.
**Сверено с документацией.** Собственная колонка с именем виртуальной делает
виртуальную недоступной. При вставке в `Distributed` шард выбирается по ключу
шардирования; фоновый режим — умолчание, а `distributed_foreground_insert = 1`
завершает вставку только после записи на все шарды. У семейства `Replicated*`
есть дедупликация одинаковых блоков. Голый `count()` по `ReplacingMergeTree`
зависит от того, сколько мержей прошло. `ttl_only_drop_parts` снимает кусок
целиком и только когда истекли все строки в нём, а сам TTL исполняется на
фоновых мержах. Kafka-движок начинает читать топик, когда к нему привязывают
матвью, и одна группа потребителей на кластер спасает от дублей. Таблицы с
одинаковым путём в keeper становятся репликами друг друга, а макрос `{uuid}`
завязан на движок базы `Atomic`. `ON CLUSTER` ждёт все хосты и бросает по
таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает.
`parseDateTime` и его родня принимают пояс необязательным последним аргументом,
а без него берут пояс сессии — он же по умолчанию серверный. У колонки с
объявленным поясом значения приводятся к нему; у колонки без объявленного
`session_timezone` перекрывает серверную настройку на выводе, но тип, с которым
считают функции, остаётся прежним (сверка 8 августа 2026 года).
**Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении
#37 (четыре 5 августа 2026 года, пятый 6 августа), пять при исполнении #43
(7 августа) и четыре при #63 (8 августа). Все подтвердили то, что здесь
написано.
- Разбор строки берёт пояс у сессии, а не из строки. Под
`session_timezone = 'Europe/Samara'` одна и та же строка
`2026-06-01T01:24:09Z` по маске `%Y-%m-%dT%H:%i:%SZ` дала 1780262649 без
третьего аргумента и 1780277049 с аргументом `'UTC'` — ровно четыре часа
разницы. Тип результата без аргумента — `Nullable(DateTime('Europe/Samara'))`.
Отсюда имя пояса в разборе: суффикс `Z` маска съедает и выбрасывает.
- `toDate` берёт пояс у типа своего аргумента. Из одного момента:
по `DateTime('UTC')` — `2026-05-31`, по `DateTime('Europe/Samara')` —
`2026-06-01`. Отсюда форма правила: имя пояса нужно там, где его не несёт тип.
- У колонки без объявленного пояса глаз и `GROUP BY` расходятся. Замер снят до
правки типов и на нынешнем стенде не повторяется — колонка уже с поясом.
Событие
`WatchID = 113504893317`, `EventDate` = `2026-06-05`: без настроек колонка
показана `2026-06-04 20:58:56`, под `session_timezone = 'Europe/Samara'` —
`2026-06-05 00:58:56`, а `toDate(UTCEventTime)` в обоих случаях
`2026-06-04`. То есть вывод колонки идёт по поясу сессии, а функция — по
поясу типа, и тип на сессию не смотрит. Родной клиент и HTTP ведут себя
одинаково.
- С объявленным поясом расхождение уходит. Стенд поднят с нуля уже по
конвенции; событие `WatchID = 384218330540`, `EventDate` = `2026-06-01`:
колонка `DateTime('UTC')` показана `2026-05-31 23:37:00` и без настроек, и
под `session_timezone = 'Europe/Samara'`, по родному клиенту и по HTTP.
`toDate(UTCEventTime)` даёт `2026-05-31`, `toDate(UTCEventTime,
'Europe/Samara')` — `2026-06-01`, `EventDate` — `2026-06-01`: расхождение
`toDate(UTCEventTime)` с `EventDate` осталось, это мир, а не пояс колонки.
- Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над
`stg.hits_raw_dist`, а пишет в неё матвью приёма — и события доезжают до
`ods.event`; значит блок она видит. В документации ClickHouse случая нет
вовсе, до 7 августа утверждение держалось на опыте владельца.
- Упавшая матвью роняет вставку и останавливает потребление до починки.
Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за
двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, а
офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение
доехало само, без повторной отправки, отставание ушло в ноль, событие
разобралось. Сносить надо именно распределённую таблицу: вставка в
`Distributed` кладёт блок в спул и сразу возвращает управление, так что на
сносе локальной ошибка всплыла бы фоном и утверждение показалось бы
опровергнутым.
- `_load_ts` в `ods.event` — это метка исходной строки сырья, а не время
разбора. Сверено по `WatchID` на двух тысячах событий модельного дня,
залитого дважды: у каждого события метка совпала с меткой одной из двух его
доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди
доставок» ноль, то есть `now64()` в матвью разбора нет.
- Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу
после, легло в сырьё и не попало в ODS никуда — ни в событие, ни в ошибки;
то же сообщение через минуту разобралось штатно. Воспроизведено дважды
7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация
файлов DDL, только приходит он с другой стороны — не при первом создании, а
при замене матвью на работающем стенде. Практический вывод один: правишь
матвью — не верь ближайшей отправке, повтори её. Чем именно держится
задержка, не измерено; наблюдение записано как наблюдение.
- Форма ключа `ods.event` принимается такой, как её задумала спека: выражение
`intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а
`SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11
мастер-спеки; ответ — DDL применяется и таблица работает.
- `RawBLOB` даёт ровно одну строку на каждое непустое сообщение. Три сообщения
с ключами, поставленные в очередь до одного сброса продюсера, стали тремя
строками с тремя разными офсетами: пачка продюсера границы сообщений не
стирает. Запасной формат `LineAsString` не понадобился. Про границу «непустое»
— сразу ниже.
- Меток времени у Kafka-движка две, и разрядность у них разная: `_timestamp` —
`Nullable(DateTime)`, `_timestamp_ms` — `Nullable(DateTime64(3))`. Отсюда
форма `kafka_timestamp` в разделе о служебных колонках.
- `DEFAULT hostName()`, объявленный только на локальной таблице, вычисляется на
шарде-получателе. Строки, вставленные с ноды 1 и уехавшие на ноду 2, несут в
этой колонке ноду 2, а в соседней, заполненной явным `hostName()` во
вставляющем `SELECT`, — ноду 1. Довод за то, чтобы служебные колонки
заполняла матвью выражением, держится. Отсюда же и невосстановимость: после
записи в `Distributed` имя читавшей ноды взять больше неоткуда — своей
колонкой оно не сохранено, а умолчание назовёт получателя.
- `DROP PARTITION` и `REPLACE PARTITION` по `Distributed` не работают: обе
операции отвечают кодом 48, «Table engine Distributed doesn't support
partitioning», и оба шарда остаются нетронутыми.
- Автосоздания топиков ClickHouse не просит, и переключателей здесь два. На
брокере автосоздание разрешено: `auto.create.topics.enable=true`, и это
умолчание образа, а не наша настройка. На клиенте — выключено: чтец с матвью,
наведённые на несуществующий топик, ждали его с ошибкой «Broker: Unknown topic
or partition», и топик не появился. Значит, от тихого топика с одной партицией
стенд бережёт клиентское умолчание, а не брокер.
Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись
Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`)
читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа
2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, —
в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при
чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на
том же наборе даёт ровно те же две строки и тот же офсет.
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он.
Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
и на распределённой таблице, могло бы вычислиться до раскладки по шардам. То
есть вывод опыта надёжен именно для умолчания, живущего только на локальной
таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет:
служебные колонки мы заполняем выражением при любом ответе.
**Проверка замены партиций 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 и переехали выше.
+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).
+509
View File
@@ -0,0 +1,509 @@
# Проверки: карта целей
Документ отвечает на два вопроса. Первый: что именно утверждает каждая цель
`make` и сколько стоит её прогон. Второй, ради которого документ и заведён:
куда положить новую проверку — так, чтобы это решалось по карте, а не чтением
скриптов.
Зона ответственности у документа одна — проверки. Что именно они стерегут,
описано в других местах: устройство хранилища — в
[storage.md](storage.md), замысел стенда — в спеке [«Боевой реализм стенда
(v2)»](../specs/2026-07-30-stand-v2-realism.md).
## Ось: кого спрашивают
Цели различаются не ценой, а тем, к кому обращён вопрос. ClickHouse отвечает
сам и за миллисекунды. Airflow отвечает через такт планировщика. Superset —
через сессию и обход метаданных. Быстрота выходит следствием этого различия, а
не критерием деления.
Совпадение цены и назначения здесь побочное, и это видно по дырам, которые
каждая цель оставляет соседке. `make smoke` не заметит перепутанных местами
макросов `shard`: обе ноды здоровы и порты отвечают. `make check-clickhouse` не
заметит потерянного подключения Superset: он про ClickHouse и только.
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
против описи — вопрос к ClickHouse: строки в `ods.event` считает сам
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене
они подошли бы смоуку.
## Карта целей
Цели живут за двумя дверями. В корне — цели стенда; в `generator/` — цели
генератора: он отдельный пакет со своим `pyproject.toml`, локом и образом, и
спрашивают его отдельно.
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
`make smoke` и `make check-services` перемерены 28 августа 2026 года,
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с
замера 6 августа.
| Цель | Откуда | Что утверждает | Стенд | Цена |
|---|---|---|---|---|
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
разными дверями охватывают разное: корневая не видит ни одного файла
генератора, генераторная — ни одного файла стенда. Человек в корне видит
зелёное и может решить, что зелёный весь репозиторий.
Две тонкости, которых не видно из таблицы:
- **Версия ruff у корневой цели закреплена в самом вызове** (`uvx ruff@0.16.1`)
и равна той, что в `generator/uv.lock`. Равенство держится руками: обновили
лок — поправьте и вызов, сверять их некому.
- **Охват держат аргументы вызова, а не устройство дерева.** Python,
положенный вне двух названных в таблице путей, не проверит ни одна из дверей.
**Корневого `typecheck` нет, и это решение, а не пропуск.** Проверке типов мало
прочитать файл: чтобы понять `from airflow.sdk import dag`, ей нужен
установленный Airflow, а он живёт в образе, не на машине — 132 пакета ради двух
файлов пробников (замер 9 августа 2026 года, `uv pip compile`). Цена решения
называется честно: две настоящие находки `ty` в `dags/test_kafka.py` постоянного
сторожа не получают. Вопрос вернётся на этапе 5, когда придут настоящие даги:
тогда окружение окупится, и условие возврата — именно это, а не «стало
неудобно».
Цена самого `make up` с пустых томов — 8 м 18 с до переезда жизненного цикла в
Airflow. В цену целей она не входит. На готовом мире подъём больше не
пересчитывает данные: `world_initialize` зеленеет без изменений.
`make rebuild-storage` тоже не проверка. Команда через Airflow удаляет
прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL,
заливает стартовый мир и возвращает его пульт к концу стартовых дней. Службы и
их тома она сохраняет, поэтому возврат к каноническим данным не требует
холодного запуска всего стенда.
## Правило: смоук обязан оставаться быстрым
`make smoke` — быстрая проверка для регулярного прогона: её гоняют не
задумываясь, и потому она обязана укладываться в секунды. Тяжёлые проверки в
неё включать не следует: тяжёлая — та, из-за которой смоук перестаёт быть
быстрым. Поодиночке это единицы и десятки секунд, вместе — минуты. Такие
проверки живут в `make check-services`.
На практике дорого обходится не работа, а ожидание службы: такт планировщика
Airflow, сессия Superset. `make check-clickhouse` создаёт таблицы, вставляет
строки и гоняет распределённые DDL — и укладывается в семь секунд, потому что
ClickHouse отвечает сразу.
Первый абзац — правило, второй — наблюдение, по которому тяжёлую проверку
узнают заранее, не замеряя.
## Какую проверку когда запускать
Деление целей ценно ровно до тех пор, пока оно не превратилось в «гонять
всегда всё».
Состояние перед проверкой восстанавливается в самой узкой достаточной области:
сначала объект или партиция, затем база слоя. Если нужен целиком чистый
прикладной мир, `make rebuild-storage` пересобирает его базы, топики, позиции
потребителей и данные. `make clean` нужен, только когда меняется или сломано
постоянное состояние вне этих баз либо проверяется сам запуск с пустых томов.
Требование к обоснованию полного сброса задаёт `AGENTS.md`. Неизвестную поломку
сначала исследуют: полный сброс уничтожает её следы.
| Когда | Что гонять |
|---|---|
| Правка в работе | `make config-test` и `make smoke` |
| PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` |
| Приёмка этапа | все три цели на стенде; запуск с пустых томов — только для изменений начальной сборки или постоянного состояния служб |
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
дорогая. Правки дагов и Superset — корневой `make lint`.
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
узнать, тот ли получается мир, его надо получить. Место выбрано по той же
оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не
нужен, — и
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
поломкой хранилища, а не забытой пересборкой.
## Порогов по времени здесь нет
Цена в таблице — замеренное число с датой замера, а не назначенный порог.
Автоматической проверки времени в репозитории нет и заводить её не следует.
Число, вписанное в проверку, становится законом, которого никто не выбирал:
[ADR 0004](../adr/0004-resource-limits.md) разбирает ровно этот случай — оценка
из спеки попала жёстким порогом в `make smoke`, `make smoke` стал критерием
приёмки каждого этапа, и дальше решения сверялись уже с порогом, а не с
исходным доводом. Спека генератора формулирует ту же позицию прямо:
«наблюдаемость без порогов».
Смотрит на время человек. `make smoke` и `make check-services` печатают его
сами — последней строкой `ИТОГ: пройдено N, ошибок M, время T с`. Замер при
приёмке делается руками и называется в теле PR.
## Проверка, которая не умеет краснеть, бесполезна
Правило, которое стоит держать в голове, правя любую проверку. Проверка,
никогда не видевшая своей поломки, доказывает только то, что она умеет печатать
«ЗЕЛЁНО». Убедиться дешевле всего руками: сломайте то, что она стережёт —
остановите `prometheus`, удалите служебную таблицу пробника на второй ноде, — и
посмотрите, покраснеет ли прогон и назовёт ли виновника. Не покраснел —
проверка не работает, и чинить надо её, а не стенд.
## Интеграционная проверка постоянной целью не становится
Проверка, которая не спрашивает стенд, а **кормит** его — шлёт сообщения,
вставляет строки, портит данные нарочно, — постоянной целью `make` не живёт.
Такую проверку гоняют один раз при исполнении, убеждаются, что механизм
работает, и оставляют след записью в теле PR. Регулярная жизнь стенда — это
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
становится темой урока.
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырья срок
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и
их. Хуже явного мусора: явный видно.
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
Дешевле всего это выходит партицией — опыт играет модельный день за границей
оси мира, его строки ложатся в собственную партицию `EventDate`, и она
сносится по окончании. Заодно менти видит партицию как единицу работы — тот
же механизм, на котором стоит переобработка дня X.
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
что стенд произвёл сам: подневный счёт событий против описи мира ловит и
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки
Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
Правило, которое такая проверка обязана выдержать и которое стоит держать в
голове для следующей: **на растущих данных утверждать можно только про
зафиксированный кусок.** Мир растёт — менти переиграет день, этап 5 добавит
следующий, — и всякое утверждение про таблицу целиком однажды покраснеет
законно, то есть впустую. Как это сделано здесь, написано в самом скрипте.
## Корректность процессов живёт в дагах DQ, а не в целях `make`
Цели `make` отвечают на вопрос «стенд собран и работает». Вопрос «данные в нём
правильные» — работа отдельных дагов качества данных: они приходят вместе со
следующими уровнями хранилища и заберут многое из того, что просится проверкой
сейчас. Поэтому находка «это стоило бы проверять регулярно» сначала проверяется
вопросом, не туда ли ей: если она про свойства данных — про полноту дня, про
свежесть слоя, про сходимость витрины с источником, — её дом даг, а не
`check-clickhouse`.
Разница видна и менти, и она содержательная. Цель `make` он зовёт руками,
когда чинит стенд; даг DQ идёт по расписанию рядом с остальным конвейером,
пишет историю проверок и разбирается как обычная задача Airflow — то есть учит
тому, как качество данных устроено в бою.
## Устройство скриптов
| Цель | Скрипт |
|---|---|
| `make config-test` | `scripts/config-test.sh` |
| `make smoke` | `scripts/stand-smoke.sh` |
| `make check-services` | `scripts/stand-services.sh` |
| `make check-clickhouse` | `scripts/check-clickhouse.sh` |
Особняком стоит `scripts/run-dag.sh`: это не проверка, а общий REST-переход от
`make` к Airflow. `make up` запускает через него `world_initialize`, а
`make rebuild-storage` — `world_recreate`; сценарий ждёт появления дага в API,
а затем до минуты повторяет только стартовый ответ `Dag with dag_id not found`.
Прочие ответы и сетевые ошибки он не повторяет: при потерянном ответе запуск мог
уже создаться. Сценарий печатает идентификаторы дага и прогона и возвращает
вызывающему его итог. Ожидание доставки событий и запуск `orders_ingest` теперь
видны задачами `world_initialize`, а не спрятаны в shell.
Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две
проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается.
Оттуда же приходят два решения, которые видно по счёту прогонов:
- **`check_containers_survived` стоит в конце обеих целей.** Убитый за память
контейнер Docker поднимает сам, и проверка здоровья об этом промолчит
(ADR 0004). Стенд нагружает `check-services`, а увидеть последствия нужно и
тому, кто гонял один смоук. Это единственная проверка, которая считается
дважды: 21 у смоука плюс 7 у `check-services` — это 27 разных проверок.
- **Зависимости машины считает только смоук.** «На машине есть Docker, curl и
jq» — вопрос к машине, а не к службам, и на оси он стоит рядом с «стенд
собран». Для `check-services` это условие запуска: без них он не начнёт
работу и громко скажет об этом, но ЗЕЛЁНО за это не печатает.
Вход в Airflow `check-services` выполняет заново — общих переменных у двух
скриптов нет. Отдельной проверкой этот вход тоже не считается: то же самое
утверждает смоук.
## Что проверено
**Калибровка заказной стороны 30 августа 2026 года при исполнении #194.**
Пересборки эталонного мира не будет (#192), поэтому доли заказной стороны
сверены замером внутренностей генератора — окно 28 модельных дней,
канонический seed, без транспорта, — тем же приемом, каким `test_plan.py` и
`test_commerce.py` меряют мир. Команда (из `generator/`):
```bash
uv run python - <<'PY'
from collections import Counter
from clickstream_generator import day, inventory, orders, world
from clickstream_generator.seeds import CANONICAL_SEED
WINDOW = 28
class_totals: Counter[str] = Counter()
outcome_totals: Counter[int] = Counter()
delivery_totals: Counter[int] = Counter()
delay_totals: Counter[int] = Counter()
total_orders = 0
for d in range(WINDOW):
today = day.stream(CANONICAL_SEED, d)
for key, value in inventory._order_class_counts(today).items():
class_totals[key] += value
money = today.orders
total_orders += len(money)
for value in money.outcome.tolist():
outcome_totals[value] += 1
for value in money.delivery.tolist():
delivery_totals[value] += 1
for value in money.snapshot_delay.tolist():
delay_totals[value] += 1
print(total_orders, dict(class_totals), dict(outcome_totals),
dict(delivery_totals), dict(delay_totals))
PY
```
6973 заказов за 28 дней. Доли из таблицы сверки (после приоритета классов):
отменено 5,15% (ориентир мастер-спеки ~5%, в пределах; из них 3,03%
оплачен-потом-отменен и 2,12% отменен неоплаченным — веса `world.py`
95/3/2); дубль `purchase` 1,89% (ориентир ~2%, в пределах); дельта суммы
1,32% (ориентир 1–2%, в пределах); потеря `purchase` 1,78% при броске 3% —
вычеты штатные: назначенные планом покупки из броска исключены (мост
склейки), часть случаев уходит в отмену приоритетом; наблюдаемая доля
принята решением владельца (#194). Стоимость доставки — 35,5% / 49,2%
/ 15,4%, совпадает с весами `DELIVERY_KOPECKS_WEIGHTS`. Опоздание слепков
(`ORDER_SNAPSHOT_DELAY_WEIGHTS`) — доля заказов с δ>0 составила 8,39%
(ориентир мастер-спеки ~10%, в пределах). Скрипт замера в репозиторий не
входит; вывод — в отчете issue #194.
**Перезамер 30 августа 2026 года при исполнении #192.** Команды запускались
из `generator/`:
```bash
time make inventory
uv run python -m clickstream_generator batch --day 0 --days 8 \
--file /tmp/stage7-events.jsonl
for snapshot_day in 1 2 3 4 5 6 7; do
uv run python -m clickstream_generator snapshot --day "$snapshot_day" \
--file "/tmp/stage7-snapshot-${snapshot_day}.jsonl"
done
wc -c /tmp/stage7-events.jsonl /tmp/stage7-snapshot-{1,2,3,4,5,6,7}.jsonl
time make test
```
Опись пересобралась за 20,515 с. Файловый приемник записал 460 731 812 байт:
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0
за 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов
стало 437 против 407, и потяжелел сам мир — та же пересборка восьми дней
стоила 13 с, теперь 20,5. Эта правка добавила один тест — проигрыш дня.
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
30 секунд почти целиком уходил на запуск второго приложения. Увеличивать его
не стали: Superset не обещает постоянного времени `create_app()`.
После входа проверка делает три GET-запроса к уже работающему Superset: находит
`ClickHouse`, сверяет UUID и безопасный URI, затем требует схему `dm`. Так без
второго запуска проверяются сохраненное подключение и хранилище пароля. Форму
API сверили через Context7 и с исходным кодом Superset 6.1.0. `superset test-db`
отклонен, потому что тоже запускает приложение; прямой SQL к `dbs` не проверяет
соединение.
Проверка больше не подтверждает, что сам Superset использует Postgres для
метаданных. Короткий запрос к Postgres показывает лишь готовность базы, а REST
API - подключение к ClickHouse. Для `check-services` этого достаточно.
После перезапуска Superset три прогона прошли 7/7 за 71, 67 и 70 секунд.
`make config-test` прошел. `make smoke` дал 21/21 за 25 секунд; его код не
менялся, прежний замер был 17 секунд. Неверный UUID дал 5/1: красной стала
только проверка метаданных, пароль в вывод не попал.
**Приемка #156 27 августа 2026 года.** `make rebuild-storage` занял 7 минут.
На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался
`etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли
`make smoke` 21/21 и `make check-clickhouse` 10/10. Перед стендовыми сценариями
прошли `make config-test` и `make lint`.
Два прогона `world_next_day` не изменили слои; один ручной `etl_pipeline` без
параметров догнал оба дня. Полный прогон `world_live_day` занял 27 м 2 с и
завершился только после успешных DDS и DM.
Временная синтаксическая поломка `sql/dm/daily_traffic_rebuild.sql` дала
красную цепочку: DDS завершился успешно, а `daily_traffic`, DM, `etl_pipeline`
и `world_live_day` покраснели. После возврата исправного SQL ручной
`etl_pipeline` без параметров завершился успешно на позиции `12` и восстановил
слои.
Для проверки пакетного барьера `stg.hits_raw_mv` временно отсоединили командой
`DETACH` на обеих нодах. После `world_next_day` отставание Kafka составило
46 367 (19 128 + 27 239). Во время ожидания матвью вернули командой `ATTACH`.
Журнал готовности записал повторные опросы с отставанием 46 367, затем 27 239
и 0; после этого очередь `ods.event_dist` содержала 0 файлов на обеих нодах,
и ETL завершился успешно. Проверка `EXISTS` снова дала `1` на обеих нодах,
позиция равна `13`.
После пересборки `make check-services` дважды дал 5/1: только импорт Superset
не уложился в существующий тайм-аут 30 с. Диагностический пробник импорта с
ожиданием 90 с прошел 3/0. Финальный штатный повтор прошел 7/7 за 129 с.
**Перезамер 21 августа 2026 года при исполнении #124.** Образ Airflow собран с
`apache-airflow-providers-clickhousedb==1.0.0`,
`apache-airflow-providers-common-sql==2.0.1` и
`clickhouse-connect==1.6.0`; `pip check` не нашёл несовместимых зависимостей.
Адресная пересборка мира через `make rebuild-storage` заняла 3 м 5 с: все шесть
задач DDL, `pull_batch` и `parse_batch` завершились успешно. После неё прошли
смоук 21/21 за 17 с, проверка ClickHouse 10/10 и межсервисная проверка 7/7 за
100 с.
Первый `make up` после замены образа попал в короткий рассинхрон запуска
Airflow: API уже сообщил о здоровье и отдал `world_initialize` по GET, но POST
нового запуска ещё ответил `Dag with dag_id not found`. Ошибок импорта не было;
через несколько секунд повторный `make up` завершился успешно и не менял
готовый мир.
**Перезамер 21 августа 2026 года при исполнении #114.** После добавления
`kafka-exporter` и источника ClickHouse смоук прошёл 21/21 за 16–17 секунд,
`check-services` — 7/7 за 93 секунды. При остановленном экспортёре смоук дал
19/2: назвал нездоровый сервис и отсутствие одной из четырёх целей. После
возврата сервиса прогон снова дал 21/21.
После переноса #119 поверх этого состава повторный `make up` сохранил готовый
мир, смоук прошёл 21/21, проверка ClickHouse — 10/10, межсервисная — 7/7.
**Приёмка логики #119 до переноса поверх мониторинга 21 августа 2026 года.**
`make rebuild-storage` через Airflow прошёл за 3 м 9 с: позиция прошла задачи
`-1` → `0` → `8`, топики получили новые идентификаторы, группы дочитали новые
топики без отставания, а ODS принял 401 185 событий без брака и 2 851
актуальный заказ. Задача `build_generator_image` заняла по 1,5 с в двух
прогонах.
Повторный `make up` занял 2 м 41 с вместе со штатным повторным запуском служб
инициализации; `world_initialize` завершился за один цикл опроса и оставил
счётчики неизменными. Отдельно проверены красные отказы `world_initialize` при
`-1` и `world_next_day` при отсутствующей позиции, `0` и `-1`; `lifecycle`
отказано в чтении `system.users`, но разрешено чтение `system.settings`.
Итоговый холодный `make up` из пустых томов занял 9 м 39 с после 25 с на
`make clean`. Даг создал отсутствующую позицию и закончил на `8`; ODS снова
получил 401 185 событий без брака и 2 851 заказ. У `hits` два раздела, у
`orders` один, отставание обеих групп равно нулю. На тогдашнем составе стенда
прошли смоук 20/20, проверка ClickHouse 10/10 и межсервисная проверка 7/7.
Оборванную очистку проверили после записи `world_position=-1`: остановили
первую ноду ClickHouse, и `world_recreate` покраснел на удалении баз. Следующий
`make up` тоже покраснел и оставил `-1`. После возврата ноды повторный
`make rebuild-storage` восстановил мир.
**Прототип 21 августа 2026 года при исследовании #110.** Предшествующая
shell-версия узкой пересборки за 2 м 25 с пересоздала прикладные базы, отправила
401 185 событий и 6 656 версий заказов и вернула позицию с 11 на 8. Замер
оставлен как ориентир цены, но не подтверждает нынешнюю реализацию Airflow: тот
вариант не очищал Kafka и был отклонён как второй оркестратор мира.
**Перезамер 20 августа 2026 года при исполнении #96, до переезда в Airflow.**
`make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за
3 м 35 с. Отправка семи слепков заняла около 28 секунд.
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й
секунде, обработчик DAG стал здоров примерно на 458-й. Числа здесь называют
цену нынешнего стенда, а не выносят вердикт о регрессии.
**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
24 секунды. Это цена всей цели, а не одной новой строки.
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
подряд — 0,39, 0,36 и 0,39 секунды на трёх файлах стенда; в таблице 0,4 с. На
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня
перечисляет ровно три файла стенда, из `generator/` — только файлы генератора.
Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву
от файла, и ближний выигрывает.
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка:
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka.
Повторный `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это
не оплошность — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`.
Смоук — 9 с, `check-clickhouse` — 8 с с новой девятой проверкой. `make test`
вырос до 71 с: 58 с прежних тестов плюс 13 с на пересборку восьми дней для
сверки с описью. Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
#41 и #43.
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца
топика». Затем положили в сырьё заведомо негодную строку — брак появился, и
диагноз сменился на второй, с признаком для различения: «сойдётся недостача с
числом брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки
опыта убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт
вернулся к 401 185, и это заодно показало дедупликацию: повтор дня не удвоил
счёт под `FINAL`.
Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и
совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону:
смоук дал 8 секунд дважды (без проверки Kafka, до её появления, — 5 и 6),
`check-services` — 43 и 44. В таблице стоит большее из замеренных.
Перемер 7 августа 2026 года — приёмка этапа 2 целиком: `make clean` и `make up`
подряд заняли 2 м 58 с и залили стартовый мир, дальше по порядку все три цели
на стенде и три без него. Смоук дал 9 секунд, `check-clickhouse` — 8,
`make test` — 71. `check-services` — 59 и 48; в таблицу по прежнему правилу
пошло большее. Против 44 секунд накануне цель подорожала, и причину назвать
нечем: между замерами и стартовый мир в хранилище, и холодный подъём перед
первым прогоном. Число здесь — замер с датой, а не бюджет, и повод разбираться
появится, когда за него кто-нибудь начнёт держаться.
До деления `scripts/stand-smoke.sh` шёл 48 секунд на 25 проверок, из них
42 секунды съедали шесть: Kafka с машины, два запуска пробников Airflow и три
проверки Superset. После деления те же 25 проверок разошлись по двум целям:
19 в смоуке и 6 в `check-services`. Содержание ни одной из них не менялось.
Двадцатая проверка смоука — единственная новая: Kafka спрашивают с машины через
отображённый порт. Деление оставило дыру, которой раньше не было. Про Airflow,
Superset, Prometheus и Grafana смоук стучится с машины в отображённый порт, а
про Kafka после переезда знал только «контейнер здоров» — а это вердикт
проверки состояния из `compose.yaml`, и та спрашивает брокер изнутри по
внутреннему слушателю. Внешняя дверь оставалась непроверенной до
`check-services` с его минутой, хотя генератор пишет в Kafka именно с
машины. Стоит проверка 2,6 секунды, и почти всё это — старт JVM в разовом
контейнере; ожидание ответа ограничено пятнадцатью секундами, впятеро больше
замеренного.
Что обе разделённые цели умеют краснеть, проверено руками в тот же день. Со
снятым `prometheus` смоук дал три ошибки и ненулевой код возврата. С
подменённым ожидаемым UUID подключения Superset так же покраснел
`check-services`. Новую проверку Kafka проверили её собственной поломкой:
брокеру объявили адрес `kafka-nowhere:29092`, оставив внутренний слушатель
целым, — проверка состояния контейнера осталась зелёной, а смоук покраснел
именно на этой строке. Краснеют они и когда на машине не хватает команды из
списка зависимостей.
Семантика счётчиков Docker `OOMKilled` и `RestartCount`, на которой держится
`check_containers_survived`, снята отдельными контейнерами и записана в
ADR 0004, раздел «Что проверено».
Разбор Bash из `make config-test` срезан 6 августа 2026 года, и вместе с ним —
двенадцать строк обхода репозитория со списком файлов. Проверка была сломана
с рождения: `bash -n` со списком файлов разбирает только первый, остальные
уходят ему в аргументы, — то есть из пяти скриптов проверялся один, и за всё
время цели этого никто не заметил. Заводить её заново незачем: скрипты стенда
запускаются с той же машины, и синтаксическая ошибка вылезает при первом же
запуске с номером строки. Разбор файлов DAG остался и по другому основанию —
их на машине не запускает никто.
+9 -7
View File
@@ -2,7 +2,7 @@
Документ собран из контракта схемы генератора
(`generator/src/clickstream_generator/schema.py`). Руками не править —
пересобрать: `make docs`.
пересобрать: `make docs` из каталога `generator/`.
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
@@ -11,10 +11,12 @@
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное
имя». Это имя источника, приведённое к snake_case, а не имя атрибута в
модели данных: слой DDS складывает свою модель и называет атрибуты по ней.
Имена и типы колонок описывают сторону источника. Обычно ODS принимает их без
изменений. Исключение — `purchaseRevenue`: источник объявляет Float64, а ODS
хранит деньги в Decimal. Точный локальный тип назван в документации
хранилища. Имя в нашем стиле ждет в столбце «Нормализованное имя». Это имя
источника, приведенное к snake_case, а не имя атрибута в модели данных: слой
DDS складывает свою модель и называет атрибуты по ней.
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
контрактом.
@@ -38,7 +40,7 @@
| 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется |
| 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один |
| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события в часовом поясе счётчика; по ней режется партиция. Дату из `UTCEventTime` не выводить: у ночных событий она на сутки другая |
| 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики; сутки же считаются в поясе счётчика, поэтому `toDate(UTCEventTime)` ≠ `EventDate` |
| 6 | `UTCEventTime` | `DateTime('UTC')` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики; сутки же считаются в поясе счётчика, поэтому `toDate(UTCEventTime)` ≠ `EventDate` |
| 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах |
| 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет |
| 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт |
@@ -90,7 +92,7 @@
| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий |
|---|---|---|---|---|---|
| 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` | валюта заказа |
| 40 | `purchaseCoupon` | `Array(String)` | `object` | `purchase_coupon` | купон заказа, если был применён |
| 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`.
@@ -0,0 +1,216 @@
# Формат дневного слепка заказов на проводе
Дата исследования: 2026-08-16.
Учебный результат: менти различает бизнес-время, время источника, доставки и
загрузки, не разбирая ради этого лишнюю инфраструктуру. Цена — несколько явных
правил контракта; новых полей и универсального сериализатора не требуется.
## Короткий вывод
- `created_at` и `updated_at` — аудит строки в источнике, а не время покупки.
Оба поля передаются в UTC с настоящей точностью до миллисекунд:
`2026-06-03T14:21:07.123Z`.
- В ClickHouse им соответствует `DateTime64(3, 'UTC')`. Неверная строка даёт
`NULL` и уходит в `*_errors`, а не превращается в правдоподобную дату.
- `snapshot_date` — дата завершившегося модельного дня, состояние которого
снято на исходящей границе суток. Внутри одной выгрузки она одинакова, на
следующем модельном дне меняется.
- `items` на проводе — обычный массив JSON. Тип `String` в ODS означает, что
из внешнего JSON извлекли сырой фрагмент массива, а не что источник дважды
сериализовал JSON.
- Генератору достаточно собрать один словарь с вложенным списком и один раз
вызвать `orjson.dumps`. Отдельная иерархия кодеков урока не добавляет.
## Оси времени
В потоковой обработке время события принадлежит самой записи и не зависит от
часов обработчика; время обработки отвечает на другой вопрос
([Apache Flink: Event Time и Processing Time](https://nightlies.apache.org/flink/flink-docs-stable/docs/concepts/time/)).
Debezium проводит ту же границу внутри одного сообщения: время изменения в
исходной БД хранится отдельно от времени обработки коннектором, а их разность
можно использовать как задержку
([документация коннектора PostgreSQL](https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-create-events)).
| Поле | Чьи часы | На какой вопрос отвечает | Форма |
|---|---|---|---|
| `UTCEventTime` события `purchase` | бизнес-событие, трекер | когда покупатель подтвердил покупку | отдельный контракт кликстрима; связь с заказом по `purchaseID = order_id` |
| `created_at` | база источника | когда строка заказа впервые создана в источнике | RFC 3339 UTC с тремя знаками долей секунды |
| `updated_at` | база источника | когда эта строка в последний раз изменена в источнике | тот же формат; версия состояния заказа |
| `snapshot_date` | модельный календарь | состояние какого завершившегося дня снято на границе суток | `YYYY-MM-DD`, без времени |
| `kafka_timestamp` | транспорт | когда брокер пометил доставленное сообщение | служебная колонка хранилища |
| `_load_ts` | хранилище | когда строка впервые приехала в хранилище | `DateTime64(3, 'UTC')` |
`updated_at` как метка последнего изменения исходной строки совпадает с
рекомендованным смыслом `updated_at` в timestamp-стратегии dbt snapshots;
время выполнения самого слепка dbt хранит отдельно
([официальная документация dbt](https://docs.getdbt.com/docs/build/snapshots#timestamp-strategy-recommended)).
Служебные метки ETL также являются отдельными метаданными процесса, а не
бизнес-фактами
([Kimball Group: Audit Dimension](https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/audit-dimension/)).
В этом проекте транспортная и складская оси уже разведены в
[конвенции хранилища](../architecture/storage.md): `_load_ts` ставится один раз,
а миллисекундный `kafka_timestamp` не округляется.
`created_at` и `updated_at` приезжают в сообщении источника; ClickHouse их не
создаёт и добавляет рядом собственную `_load_ts`.
Следствие для контракта: `created_at` нельзя называть временем покупки, а
`updated_at - created_at` — длительностью бизнес-перехода. Это время между
созданием и последним изменением строки в источнике. Бизнес-время живёт в
событии `purchase` и связывается с заказом по уже существующему ключу.
### Партиция и бизнес-время
`created_at` и `updated_at` — аудит строки источника. Поэтому
`toDate(created_at)` в
[спецификации приёма заказов](../architecture/orders/ingestion.md)
используется как стабильный технический ключ партиции `ods.order`:
это день создания строки
источника, а не доказательство дня бизнес-события.
Минимальное решение — не добавлять `ordered_at` на всякий случай. Пока модель
создаёт исходную строку синхронно с покупкой, существующий ключ партиции можно
оставить, но в описании называть его днём создания строки. Время покупки для
сверки берётся из `purchase.UTCEventTime`. Отдельное поле в заказе понадобится
только тогда, когда появится самостоятельный учебный запрос к бизнес-времени
заказа или источник начнёт сохранять заказ асинхронно. Так различие остаётся
честным, но не порождает поле без потребителя.
## Точность и строгий разбор
RFC 3339 — профиль ISO 8601 для обмена датой и временем. Он разрешает дробную
часть секунды переменной длины и как `Z`, так и числовое смещение
([RFC 3339, §5.6](https://www.rfc-editor.org/rfc/rfc3339#section-5.6)). Значит
миллисекунды не следуют из названия стандарта сами по себе. Наш более узкий
контракт фиксирует ровно три цифры и UTC:
```text
YYYY-MM-DDTHH:mm:ss.SSSZ
```
Например: `2026-06-03T14:21:07.123Z`. Одинаковое число цифр дробной части и
одинаковая зона дают хронологическую сортировку таких строк в лексикографическом
порядке
([RFC 3339, §5.1](https://www.rfc-editor.org/rfc/rfc3339#section-5.1)). Три
цифры выбраны потому, что источник моделирует миллисекунды. Сериализатор всегда
выводит все три цифры, в том числе `.000` для значения точно на границе секунды.
Нельзя только выдавать секундную модель за миллисекундную простым дополнением
нулей.
В ClickHouse `DateTime64(3, 'UTC')` хранит три десятичных знака долей секунды,
то есть миллисекунды; пояс колонки используется при разборе и показе значения
([DateTime64](https://clickhouse.com/docs/sql-reference/data-types/datetime64)).
Для этого узкого формата подходит обнуляемый разбор по точному шаблону:
```sql
parseDateTime64InJodaSyntaxOrNull(
value,
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'',
'UTC'
)
```
`OrNull` возвращает `NULL` при несовпадении, а три `S` задают точность
`DateTime64(3)`
([документация функции](https://clickhouse.com/docs/sql-reference/functions/type-conversion-functions#parsedatetime64injodasyntaxornull),
[исходный код ClickHouse](https://github.com/ClickHouse/ClickHouse/blob/master/src/Functions/parseDateTime.cpp)).
Это строже, чем `parseDateTime64BestEffortOrNull`: функция Best Effort по
назначению принимает несколько представлений даты, тогда как здесь форма сама
является частью учебного контракта
([документация Best Effort](https://clickhouse.com/docs/sql-reference/functions/type-conversion-functions#parsedatetime64besteffortornull)).
На проектном ClickHouse 26.3.17.56 это выражение локально проверено. Оно
возвращает `Nullable(DateTime64(3, 'UTC'))` для строки с `.123Z` и `NULL` для
строки без миллисекунд, с четырьмя цифрами, со смещением `+00:00` вместо `Z`
или с хвостовым мусором. Поэтому один и тот же результат разбора можно
использовать и для типизированной строки, и для маршрутизации ошибки; нулевая
дата не нужна.
> Актуальный стенд: версию `ods.order` задаёт `snapshot_date`
> ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Следующий
> абзац фиксирует исходный вывод исследования и не описывает текущий контракт.
`updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse
явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной
версией
([ReplacingMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replacingmergetree)).
Если две версии одного заказа имеют одинаковый `updated_at`, среди них
побеждает вставленная позже. Для учебной модели достаточно гарантировать
монотонные миллисекундные `updated_at` на один заказ; отдельный счётчик версий
без такого сценария был бы лишним.
## Дата слепка и константы мира
Периодический слепок имеет зерно заранее заданного периода — например, дня, —
а не отдельной транзакции
([Kimball Group: Periodic Snapshot Fact Tables](https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/periodic-snapshot-fact-table/)).
Поэтому `snapshot_date` — значение пачки: дата завершившегося модельного дня D,
состояние которого снято на границе D|D+1. Это не UTC-дата отправки и не
глобальная константа.
На один проход генератор вычисляет дату один раз и кладёт её во все записи; на
следующем модельном дне значение меняется. Настоящие константы мира — начало
модельной оси `ORIGIN`, пояс счётчика `COUNTER_TIMEZONE_MINUTES` и окно K = 7.
Первые две уже заданы в
[модели времени](../../generator/src/clickstream_generator/world.py), окно
добавится в конфигурацию мира вместе с заказами. Глобальная `SNAPSHOT_DATE`
смешала бы правило календаря с результатом его вычисления.
На старте оси дня −1 нет, поэтому прогон дня 0 ничего не отправляет. Первый
слепок с `snapshot_date = ORIGIN` уезжает прогоном дня 1.
`as_of_date` тоже могло бы означать дату, по состоянию на которую показаны
данные. Но `snapshot_date` уже является языком спеки и ADR о приёме заказов.
Переименование не добавляет урока и может спутать дату
выгрузки с периодом бизнес-действия записи. Для этого стенда оставляем
`snapshot_date`.
## JSON и сериализация
В JSON массив и строка — разные типы значения: массив содержит значения
непосредственно, а строка содержит последовательность символов
([RFC 8259, §§3, 5 и 7](https://www.rfc-editor.org/rfc/rfc8259)). Поэтому форма
на проводе такая:
```json
{
"order_id": "20260603-0001",
"user_id": 42,
"status": "paid",
"created_at": "2026-06-03T14:21:07.123Z",
"updated_at": "2026-06-03T14:24:18.456Z",
"items_total": "1299.90",
"discount": "0.00",
"delivery": "199.00",
"total": "1498.90",
"items": [
{"sku": "sku-17", "qty": 1, "price": "1299.90"}
],
"snapshot_date": "2026-06-07"
}
```
ClickHouse `JSONExtractRaw(raw, 'items')` возвращает выбранный фрагмент JSON
неразобранной строкой
([официальная документация](https://clickhouse.com/docs/sql-reference/functions/json-functions#jsonextractraw)).
На проектной версии локальная проверка обычного внешнего JSON показала
`JSONType(..., 'items') = 'Array'`, а `JSONExtractRaw` вернул компактный текст
массива, пригодный для колонки ODS `String`. Строка с JSON внутри потребовала
бы экранировать массив при первой сериализации и разбирать его второй раз, не
меняя результат в ODS.
`orjson.dumps` умеет сериализовать вложенные словари и списки напрямую и
возвращает JSON в UTF-8
([официальный репозиторий orjson](https://github.com/ijl/orjson)). Поэтому
KISS-вариант для [существующего модуля сериализации](../../generator/src/clickstream_generator/serialize.py)
— подготовить канонические строки времени и денег, положить `items` списком в
общий словарь и сделать один внешний `dumps` на запись. Класс кодеков, реестр
схем и повторный `dumps` для `items` здесь ничего не учат.
Граница этого решения: `toDecimal64OrNull(..., 2)` проверяет числовую
преобразуемость, но не лексическое правило «ровно два знака» — локально строки
`1299.90`, `1299.9` и `1299.900` дали одно значение. Проверка денежного формата
не нужна сериализатору этого слепка: он сам выпускает ровно два знака. Приём ODS
проверяет ту же каноническую форму и считает остальные формы браком; граница
строгого приёма зафиксирована в
[спецификации заказов](../architecture/orders/ingestion.md).
@@ -0,0 +1,235 @@
# Соединение двух Distributed-таблиц на стенде
Дата исследования: 2026-08-22. Тикет #129, хвост раздела 11 мастер-спеки.
Учебный результат: менти видит на своём кластере, где именно выполняется
JOIN двух Distributed-таблиц, чем GLOBAL отличается от обычного соединения и
почему «правильный» ключ шардирования в DDL ещё не гарантирует ко-локацию
данных.
Проверено двумя дорогами: читающими запросами на живом стенде
(ClickHouse 26.3.17.56, 2 шарда × 1 реплика; запросы помечены
`log_comment = 'r129-*'`, разбор — по `system.query_log` обеих нод) и по
документации ClickHouse через MCP Context7 (страницы «JOIN Clause →
Distributed JOIN», «Distributed table engine», «IN operators → distributed
subqueries», настройки `distributed_product_mode`,
`prefer_global_in_and_join`, `parallel_distributed_insert_select`).
## Короткий вывод
- Обычный JOIN и `IN` с Distributed-таблицей справа режутся настройкой
`distributed_product_mode = 'deny'` — это заводское значение, стенд его не
менял. Ошибка: `DISTRIBUTED_IN_JOIN_SUBQUERY_DENIED`.
- `GLOBAL JOIN` собирает правую часть один раз на ноде-инициаторе, кладёт во
временную таблицу `_data_…` и рассылает её на все шарды; каждый шард
соединяет свой локальный кусок левой таблицы с этой копией. Это видно в
журнале запросов второй ноды дословно.
- Режим `local` переписывает правую Distributed-таблицу на локальную. Ответ
верен только при настоящей ко-локации по ключу соединения; иначе он молча
меньше правильного — ошибки не будет.
- Если левая часть — подзапрос, а не голая таблица, ClickHouse 26.3 вообще не
раздаёт соединение по шардам: обе стороны стягиваются на инициатор и JOIN
выполняется там один раз. `GLOBAL` и `distributed_product_mode` при этом ни
на что не влияют.
- Ловушка `FULL OUTER`: при голой левой Distributed-таблице непарная строка
правой части попадает в ответ по разу с каждого шарда — на нашем кластере
дважды. С подзапросами с обеих сторон ответ верный.
- Находка стенда: ко-локация заказов по `cityHash64(order_id)` сломана — 1189
из 1662 заказов лежат на обоих шардах одинаковыми строками. Виновата
настройка `parallel_distributed_insert_select = 2` (заводское значение
в 26.3): `INSERT … SELECT` из Distributed в Distributed выполнился локально
на каждом шарде, и строки заказов унаследовали раскладку сырья — по хешу
сырой строки. События целы: все 401 185 строк лежат по своему хешу
`cityHash64(ClientID)`.
## Как выполняется соединение: три наблюдаемых плана
### Обычный JOIN голых Distributed-таблиц — запрещён
```sql
SELECT count() FROM ods.order_dist AS a
INNER JOIN ods.order_dist AS b ON a.order_id = b.order_id
```
Обе версии планировщика (`enable_analyzer` 0 и 1) отвечают:
```text
Code: 288. DB::Exception: Double-distributed IN/JOIN subqueries is denied
(distributed_product_mode = 'deny'). You may rewrite query to use local
tables in subqueries, or use GLOBAL keyword, or set distributed_product_mode
to suitable value. (DISTRIBUTED_IN_JOIN_SUBQUERY_DENIED)
```
Причина по документации: запрос переписывается для каждого шарда, и правая
Distributed-таблица внутри такой переписки означала бы «каждый шард ещё раз
спрашивает весь кластер» — N шардов породили бы N×N подзапросов. Режим `deny`
запрещает это по умолчанию. Тот же запрет действует на `IN
(SELECT … FROM …_dist)` — поэтому в исследовательских запросах ниже стоит
`GLOBAL IN`.
### GLOBAL JOIN — правая часть собирается один раз и рассылается
```sql
SELECT count() FROM ods.order_dist AS a
GLOBAL INNER JOIN ods.order_dist AS b ON a.order_id = b.order_id
```
Журнал запросов второй ноды показывает оба шага (log_comment
`r129-bare-global`):
```text
SELECT order_id FROM ods.order_rep -- шард отдаёт свой кусок правой части
SELECT count() FROM ods.order_rep
GLOBAL ALL INNER JOIN `_data_14241958685358625318_…` ON … -- шард соединяет локальную левую с разосланной копией
```
Инициатор один раз прочёл правую часть со всех шардов, собрал временную
таблицу в памяти, разослал её и слил частичные результаты. Цена GLOBAL —
память инициатора и рассылка по сети, поэтому он для малой правой части;
заказы (2851 строка) малы.
### Подзапрос слева выключает раздачу по шардам
```sql
SELECT count()
FROM (SELECT arrayJoin(purchaseID) AS pid FROM ods.event_dist
WHERE notEmpty(purchaseID)) AS e
INNER JOIN ods.order_dist AS o ON e.pid = o.order_id
```
Этот запрос прошёл без ошибки даже при `deny`, и с `GLOBAL`, и во всех
режимах `distributed_product_mode` — план один и тот же: вторая нода получила
два независимых чтения (`SELECT … FROM ods.event_rep`,
`SELECT order_id FROM ods.order_rep`), а соединение выполнил инициатор,
прочитав целиком обе стороны (404 036 строк). Раздачи соединения по шардам
здесь нет вовсе, поэтому нет и «двойной распределённости», которую режим
`deny` запрещает.
## distributed_product_mode на живых данных
Самосоединение заказов, счёт пар строк. Правильные ответы посчитаны отдельно
как суммы квадратов кратностей: по `order_id` — 5229, по `user_id` — 6087.
| Ключ | GLOBAL | `local` | `allow` |
|---|---|---|---|
| `order_id` (заявленный ключ шардирования) | 5229 | **2851** | — |
| `user_id` (ко-локации нет и не заявлено) | 6087 | **3281** | 6087 |
`local` переписывает правую часть на локальную таблицу — в журнале второй
ноды виден дословный `… FROM ods.order_rep INNER JOIN ods.order_rep …`. Ответ
совпадает с суммой пошардовых соединений и верен только тогда, когда обе
строки каждой пары лежат на одном шарде. По `user_id` это ожидаемо не так. По
`order_id` ответ тоже неверен — потому что настоящая раскладка заказов ключу
не соответствует (см. ниже). `allow` разрешает наивный план «каждый шард
спрашивает весь кластер»: ответ верный, чтений — квадрат от числа шардов.
Документация даёт четыре режима (`deny`, `local`, `global`, `allow`) и
отдельную настройку `prefer_global_in_and_join`, которая молча превращает
обычные IN/JOIN в GLOBAL. Стенд ничего из этого не меняет: явный `GLOBAL` в
тексте запроса честнее незаметной настройки — читатель видит цену в самом
запросе.
## Ловушка FULL OUTER на кластере
Строка правой части, не нашедшая пары, добавляется в ответ каждым шардом:
```sql
SELECT countIf(b_flag = 1) FROM ods.order_dist AS a
GLOBAL FULL OUTER JOIN
(SELECT 'zzz-no-such-order' AS oid, 1 AS b_flag) AS b ON a.order_id = b.oid
-- ответ: 2 (одна фиктивная строка вернулась дважды — по разу с каждого шарда)
```
Так ведут себя обе версии планировщика. Механика прямая: каждый шард получил
копию правой части, у каждого она не нашла пары в его локальном куске, и
каждый честно отдал её как непарную. Стоит обернуть левую часть в подзапрос —
соединение выполняется один раз на инициаторе, и ответ становится верным:
```sql
… FROM (SELECT order_id FROM ods.order_dist) AS a GLOBAL FULL OUTER JOIN …
-- ответ: 1
```
## Находка: ко-локация заказов сломана
DDL обещает раскладку по `cityHash64(order_id)`, но на живых данных половина
строк лежит не на своём шарде:
| Шард | на своём шарде | не на своём |
|---|---|---|
| 1 | 696 | 725 |
| 2 | 750 | 680 |
1189 из 1662 заказов присутствуют на обоих шардах одинаковыми строками —
одна и та же версия, один и тот же `_load_id`. Пути реплик в keeper у шардов
разные, репликация ни при чём.
Причина — `parallel_distributed_insert_select = 2`, заводское значение
в 26.3. По документации: если выполняется `INSERT INTO distributed_a
SELECT … FROM distributed_b` и обе таблицы на одном кластере, запрос
выполняется локально на каждом шарде. Ровно это делает разбор заказов
(`sql/ods/order_parse.sql`: `INSERT INTO ods.order_dist … FROM
stg.orders_raw_dist`): каждый шард разобрал свой кусок сырья и записал его в
свою локальную таблицу, минуя ключ шардирования цели. Строки заказов
унаследовали раскладку STG — хеш сырой строки, то есть случайную относительно
`order_id`. Заказ, попавший в несколько дневных слепков, приезжает несколькими
разными сырыми строками и оседает на обоих шардах; ReplacingMergeTree
схлопывает дубли только внутри шарда.
Следствия уже наблюдаемы: `count()` по `ods.order_dist` даёт 2851 при 1662
заказах, а точное чтение работает только через `GROUP BY order_id` /
`argMax` — пошардовый `FINAL` через дубль на соседнем шарде не переступит.
События этой болезни не имеют: их пишет матвью приёма от Kafka-таблицы, не
из Distributed, и раскладка по `cityHash64(ClientID)` точна на всех 401 185
строках.
Починка — отдельный тикет, не этого исследования: разбору заказов нужно
явное `parallel_distributed_insert_select = 0` (строки поедут через ключ
шардирования цели), плюс пересборка уже разложенного. Тот же капкан ждёт
любую будущую сборку DDS вида `INSERT INTO …_dist SELECT … FROM …_dist`, у
которой ключ цели отличается от ключа источника.
## Выводы для identity_map (спека §5)
Сборка карты — соединение покупок из событий с заказами по
`purchaseID = order_id`. Ключ соединения не ключ ко-локации событий, поэтому
по политике раздела 6 спеки соединение обязано быть GLOBAL — и эмпирика это
подтверждает: обычный JOIN голых таблиц запрещён, `local` дал бы молча
неверный ответ. GLOBAL здесь дёшев: правая часть — заказы, их тысячи.
На практике левая часть сборки — подзапрос (события фильтруются по
`notEmpty(purchaseID)` и разворачиваются `arrayJoin`), а значит на 26.3
соединение выполнится один раз на инициаторе. `GLOBAL` в тексте при этом не
лишний: он выражает политику спеки, ничего не стоит и защищает запрос от
смены плана при другой форме запроса или версии сервера.
Запись результата в `dds.identity_map` (шардирование `cityHash64(client_id)`)
пойдёт тем же `INSERT … SELECT`. Источник — результат соединения, а не голая
Distributed-таблица, поэтому капкан `parallel_distributed_insert_select` на
эту вставку не распространяется; но проверку раскладки после первой сборки
стоит включить в приёмку — теперь ясно, что заявленный ключ и настоящая
раскладка умеют расходиться.
## Выводы для purchase_vs_orders_v (спека §7)
`FULL OUTER GLOBAL JOIN` для сверки корректен при одном условии: обе стороны —
подзапросы, и соединение выполняется один раз на инициаторе. Витрина это
условие выполняет естественно: покупки нужно развернуть из массива и
агрегировать (класс `duplicate_event` иначе не посчитать), заказы — привести
к последней версии. С голой Distributed-таблицей слева витрина удваивала бы
каждый заказ без события — весь класс `lost_event` — по числу шардов.
Прогон сверочной формы на живых данных (обе стороны — подзапросы с
`GROUP BY`): 1662 совпадения, 255 покупок без заказа (ждут слепка,
`awaiting_order`), 0 заказов без покупки, без задвоений. Обе величины
согласуются с прямыми счётами — форма пригодна как каркас витрины.
## Чем проверялось
Все запросы — читающие, от служебного пользователя внутри контейнеров
(`docker compose exec clickhouse-01 … clickhouse-client`), следы — в
`system.query_log` обеих нод по `log_comment` с префиксом `r129-`. Документные
утверждения (механика GLOBAL, режимы `distributed_product_mode`, поведение
`parallel_distributed_insert_select`) сверены с документацией ClickHouse через
MCP Context7; поведение FULL OUTER на кластере в документации не описано —
здесь источник только эмпирика.
@@ -0,0 +1,169 @@
# Проталкивание условий сквозь представления над FINAL
Дата исследования: 2026-08-22. Тикет #128, карта #127.
Вопрос: пробрасывает ли ClickHouse условия запроса внутрь обычного
представления над таблицей с `FINAL` (`ods.event_v`) и внутрь вложенного
представления над ним (будущее `dds.event_v`) — или разворачивает
представление целиком и фильтрует потом.
## Короткий вывод
- Пробрасывает. Обычное представление — это подзапрос в `FROM`, и условие
внешнего запроса доезжает сквозь него до партиций и первичного ключа
таблицы. Проверено планом и счётчиком прочитанных строк.
- Второй уровень вложенности ничего не ломает: сквозь подзапрос над
`ods.event_v` с переименованными колонками условие доезжает так же —
срезание партиций совпадает с прямым запросом к `ods.event_dist FINAL`
один в один.
- Условие доезжает даже сквозь вычисленную колонку: фильтр по
`toStartOfWeek(EventDate)` анализируется индексом MinMax.
- Единственное, что `FINAL` отнимает, — автоматический перенос условия в
`PREWHERE`: настройка `optimize_move_to_prewhere_if_final` по умолчанию
выключена. Срезание по индексам при этом работает полностью; теряется
только оптимизация порядка чтения колонок внутри уже выбранных гранул.
- Для контракта `dds.event_v` это значит: цепочка обычных представлений
ODS → DDS годится, представление до фильтра не материализуется.
## Как проверяли
Живой стенд, ClickHouse 26.3.17.56, `enable_analyzer = 1` (умолчание).
Только читающие запросы с ноды 1: `EXPLAIN indexes = 1`,
`EXPLAIN actions = 1`, `SELECT` со сверкой по `system.query_log`.
Вложенное представление не создавали — состояние стенда не менялось;
второй уровень эмулирован эквивалентным подзапросом.
Устройство пары: `ods.event_rep` — `ReplicatedReplacingMergeTree(_load_ts)`,
`PARTITION BY EventDate`, `ORDER BY (CounterID, EventDate,
intHash32(ClientID), WatchID)`; на момент опыта 16 активных кусков в
8 партициях, 24 гранулы. `ods.event_v` — `SELECT * FROM ods.event_dist FINAL`.
## Опыт 1. Фильтр по ключу партиции сквозь представление
```sql
EXPLAIN indexes = 1
SELECT count() FROM ods.event_v WHERE EventDate = '2026-06-01';
```
План читает `ods.event_rep` с условием `EventDate in [20605, 20605]` во всех
трёх индексах — MinMax, Partition, PrimaryKey: `Parts: 2/16`,
`Granules: 3/24`. Прямой запрос `SELECT count() FROM ods.event_dist FINAL
WHERE EventDate = '2026-06-01'` даёт те же цифры. Представление ничего не
прячет от анализа индексов.
## Опыт 2. Второй уровень: подзапрос над представлением
Будущее `dds.event_v` — представление над `ods.event_v` с переименованием в
язык бизнес-модели. Эмуляция:
```sql
EXPLAIN indexes = 1
SELECT count()
FROM (
SELECT WatchID AS watch_id, EventDate AS event_date,
lower(EventType) AS event_type
FROM ods.event_v
) WHERE event_date = '2026-06-01';
```
Условие по переименованной колонке доехало до таблицы: `Parts: 2/16`,
`Granules: 3/24` — цифры совпали с опытом 1. Два уровня разворачивания и
переименование колонок проталкиванию не мешают.
Отдельно проверена вычисленная колонка:
```sql
SELECT count()
FROM (SELECT toStartOfWeek(EventDate) AS event_week FROM ods.event_v)
WHERE event_week = '2026-06-01';
```
В плане условие `toStartOfWeek(EventDate) in [20605, 20605]` стоит в индексе
MinMax и срезает `Parts: 0/16` — ноль корректен: при умолчании функции ни
одна неделя не начинается в этот понедельник. То есть даже фильтр по
выражению от ключевой колонки анализируется индексом, пока функция ему
понятна.
## Опыт 3. Фильтр по ключу сортировки
```sql
EXPLAIN indexes = 1
SELECT count() FROM ods.event_v WHERE CounterID = 42150607;
```
Условие `CounterID in [...]` дошло до PrimaryKey (`Search Algorithm: binary
search`). Гранулы не срезались (`24/24`) — но это свойство данных, а не
представления: счётчик один, он есть в каждой грануле.
## Опыт 4. PREWHERE и FINAL
По колонке вне ключа (`EventType = 'pageview'`, `EXPLAIN actions = 1`,
запрос с широким списком колонок):
- `ods.event_rep` без `FINAL` — условие уехало в `Prewhere filter`;
- `ods.event_rep FINAL` и `ods.event_v` — условие осталось обычным `Filter`
над шагом чтения.
Причина — настройка `optimize_move_to_prewhere_if_final`, на стенде `0`
(умолчание). Это цена самого `FINAL`, а не представления: прямой запрос с
`FINAL` ведёт себя так же.
## Опыт 5. Счётчик прочитанных строк
План — обещание; исполнение сверено по `system.query_log`
(`read_rows` начального запроса, сумма по кластеру):
| Запрос | read_rows |
|---|---|
| `ods.event_v` без фильтра | 401 185 |
| `ods.event_v WHERE EventDate = '2026-06-01'` | 50 626 |
| подзапрос над `ods.event_v`, фильтр по `event_date` | 50 626 |
Фильтр сквозь представление читает только партиции своего дня, вложенность
не добавляет ни строки. 401 185 — все физические строки обеих нод.
## Что говорит документация
Сверено через MCP Context7 22 августа 2026 года, библиотека
`/websites/clickhouse`.
- Обычное представление не хранит данных: при обращении его запрос
исполняется как подзапрос в `FROM` — `SELECT a, b, c FROM view`
эквивалентен `SELECT a, b, c FROM (SELECT ...)`
(`sql-reference/statements/create/view`).
- Проталкивание предикатов в подзапросы — штатная работа оптимизатора;
анализатор доводит фильтры до хранилища, включая срезание партиций и
первичного ключа (changelog 2026, настройки семейства
`allow_push_predicate_*`).
- `optimize_move_to_prewhere_if_final` управляет автоматическим `PREWHERE`
для запросов с `FINAL` (`settings/session-settings`); на стенде она в
умолчании `0`, что и видно в опыте 4.
- `do_not_merge_across_partitions_select_final = 1` заставляет `FINAL`
обрабатывать партиции независимо; с 2026 года значение `1` включает режим
безусловно, а автоматику решает отдельная настройка
`enable_automatic_decision_for_merging_across_partitions_for_final`
(changelog 2026). К `ods.event` режим применим: партиция — `EventDate`,
версии одного события всегда лежат в одном дне. На стенде настройка `0`,
и трогать её не понадобилось — вопрос тикета решился без неё.
- `FINAL` поверх подзапроса не работает — модификатор обязан стоять у самой
таблицы (`guides/.../analyzer`, «Fixing Invalid FINAL Usage»). Наша
цепочка это соблюдает: `FINAL` живёт в один слой, внутри `ods.event_v`.
## Вывод для контракта dds.event_v
Сомнение владельца — «представление материализуется до фильтра» — не
подтвердилось. Условия по ключу партиции и ключу сортировки доезжают до
индексов сквозь два уровня обычных представлений, переименование колонок и
анализируемые выражения; исполняется это, а не только планируется.
Цепочка обычных представлений ODS → DDS → DM годится как форма контракта.
Две оговорки в контракт:
1. `FINAL` внутри `ods.event_v` отключает автоматический `PREWHERE` — на
объёмах стенда это незаметно, но слабый фильтр по неключевой колонке
при широком `SELECT` читает больше, чем без `FINAL`. Это свойство
`FINAL`, представления его не усугубляют.
2. Индекс срезает только то, что умеет анализировать: фильтр по колонке,
которую `dds.event_v` вычислит нетривиальным выражением, до индекса
может не доехать. Ключевые для срезов колонки — прежде всего
`EventDate` — стоит отдавать в DDS переименованием, а не пересчётом.
@@ -0,0 +1,132 @@
# Права DEFINER у представления над Distributed
Дата исследования: 2026-08-25. Тикет #150. ClickHouse 26.3.17.56.
## Вопрос
Почему `dds.event_v` с `SQL SECURITY DEFINER`, созданное пользователем
`lifecycle`, всё равно читает удалённый `ods.event_rep` как `bi`? Можно ли
оставить `bi` только с правом на публичное представление DDS, не заводя отдельный
маршрут или пользователя и не копируя данные?
## Короткий вывод
Простого поддерживаемого способа в ClickHouse 26.3 нет. `DEFINER` меняет
пользователя, чьи права проверяет локальный запрос представления, но сохраняет
в запросе имя его первоначального инициатора — `initial_user`. Движок
`Distributed` передаёт это имя на шарды, а межсерверный секрет делает его
пользователем удалённой сессии. Поэтому на координаторе источники читает
`lifecycle`, а на соседнем шарде физическую таблицу читает `bi`.
Это не заявленная семантика `SQL SECURITY DEFINER`: документация обещает, что
право на источник требуется определившему представление пользователю, а
вызывающему достаточно `SELECT` на представление. Поведение следует из
сочетания двух реализаций и выглядит как недокументированное ограничение или
дефект распределённого исполнения.
## Где меняется пользователь
`dds.event_v` разворачивается в запрос к `ods.event_v`, затем к
`ods.event_dist FINAL`. Для внутреннего запроса представления ClickHouse
создаёт новый контекст в
[`getSQLSecurityOverriddenContext`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/StorageInMemoryMetadata.cpp#L135-L175):
1. копирует исходный `ClientInfo` целиком, включая `initial_user = bi`;
2. затем отдельно вызывает `setUser` для определившего представление
пользователя `lifecycle`.
В контексте остаются два имени с разными задачами: `lifecycle` отвечает за
локальную проверку прав, а `bi` остаётся первоначальным пользователем запроса.
Второй `SQL SECURITY DEFINER` на `ods.event_v` повторяет те же два шага и
поэтому ничего не меняет.
## Что происходит на границе шарда
`Distributed` копирует `ClientInfo` из контекста и отправляет его на удалённую
ноду без замены `initial_user`
([`RemoteQueryExecutor.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/QueryPipeline/RemoteQueryExecutor.cpp#L415-L444)).
При межсерверном секрете имя входит в подписываемые данные запроса
([`Connection.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Client/Connection.cpp#L968-L994)).
Принимающая нода проверяет подпись и открывает сессию с
`AlwaysAllowCredentials{client_info.initial_user}`
([`TCPHandler.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Server/TCPHandler.cpp#L2303-L2356)).
Секрет здесь выполняет штатную функцию. Это не случайный побочный эффект.
Документация `Distributed` прямо говорит: если задан `<secret>`, `initial_user`
становится текущим пользователем удалённого запроса
([описание кластера](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/docs/en/engines/table-engines/special/distributed.md#L105-L118)).
Так права, роли и ограничения исходного пользователя одинаково действуют на
всех шардах: координатор подтверждает личность пользователя, но не превращает
его запрос в запрос своей служебной учётки. Протокол не передаёт отдельно
эффективного пользователя `DEFINER`, поэтому на стенде эта защита и
останавливает `bi` у `ods.event_rep`.
## Рассмотренные варианты
### Настройка или другая форма представления
В 26.3 нет настройки, которая подменяет `initial_user` на определившего
представление пользователя только для чтения `Distributed`.
`push_external_roles_in_interserver_queries` пересылает роли именно
`initial_user`, не роли `DEFINER`
([исходник отправки](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/QueryPipeline/RemoteQueryExecutor.cpp#L425-L444)).
`distributed_ddl_use_initial_user_and_roles` относится только к заданиям
`ON CLUSTER`. На `SELECT` настройка не действует
([объявление настройки](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Core/ServerSettings.cpp#L1002-L1006)).
### Пользователь в существующем описании кластера
С межсерверным секретом ClickHouse выбирает специальную межсерверную
аутентификацию и не использует пароль из описания реплики
([`Connection::sendHello`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Client/Connection.cpp#L452-L476)).
Чтобы удалённый запрос всегда шёл как `lifecycle`, пришлось бы убрать секрет и
передавать его пароль. Тогда между нодами перестанет передаваться исходный
пользователь. Одна модель доступа сменится другой, менее прозрачной.
### `remote()` с явными реквизитами
Для `remote()` ClickHouse умеет заменить `initial_user` пользователем из
адреса
([`executeQuery.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Interpreters/ClusterProxy/executeQuery.cpp#L96-L120)).
Но представлению понадобятся адреса и реквизиты удалённого пользователя — в
тексте DDL либо в отдельной настройке. Это и есть отдельный маршрут доступа;
он сложнее нынешнего кластера и требует хранить ещё один секрет.
### Промежуточное локальное представление на шарде
Можно направить `Distributed` на локальное представление с `DEFINER`, но
удалённому пользователю всё равно нужен `SELECT` на это представление. Значит
появятся дополнительный объект и дополнительный грант, а `bi` увидит
поверхность одного шарда. Физическая таблица останется закрытой, но простота и
контракт «только публичная поверхность DDS» исчезнут.
### Ослабление проверки
- `SQL SECURITY NONE` снимает проверку источника, но устарело и позволяет
создателю такого представления исполнять произвольный запрос; ClickHouse
требует для этого отдельное право
([документация](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/docs/en/sql-reference/statements/create/view.md#L115-L136)).
- Право `bi` на физическую ODS-таблицу решает ошибку без новой механики, но
ослабляет исходную границу.
- Физическая или материализованная копия в DDS сохраняет границу, но добавляет
хранение и жизненный цикл данных ради переименования колонок.
## Решение для стенда
25 августа 2026 года выбрана простота: роль `bi_reader` читает ODS, DDS и DM
целиком. Потребительские запросы продолжают обращаться к `_v`. Этот договор
держат документация и ревью; границы прав задают гранты.
Отдельный межсерверный маршрут, служебная учётка и физическая поверхность DDS
отклонены: каждый вариант добавляет механику только ради защиты, которой стенд
не учит. Готовой настройки, которая сохранила бы и узкую границу, и простое
устройство, в закреплённой версии нет.
## Граница уверенности
Механизм подтверждён исходным кодом точной версии 26.3.17.56. Простую настройку
искали в настройках сервера и в путях формирования удалённого запроса этой же
версии. Уверенность в обоих выводах высокая. Официального
текста, который называет сочетание `DEFINER + Distributed + secret`
поддерживаемым ограничением или зарегистрированным дефектом, не найдено;
классификация как дефекта остаётся выводом из расхождения документации и кода.
+279
View File
@@ -0,0 +1,279 @@
# Инвентаризация сюжетов лаб по слоям стенда
Это материал для спеки программы лаб (#202): полный список сюжетов без отбора. Спека решит, что оставить и в каком порядке поставить. Здесь для каждого участка стенда записано, чему на нем научится менти. Тикет — #201, карта — #182.
Каждый сюжет описан четырьмя строками: **урок**, **материал**, **форма** и **проверка**. Возможны три формы:
- *построить свое по ТЗ* — как в пяти домашних заданиях DM: рядом есть образец, но нет готового ответа;
- *вычитание* — удалить SQL готовой трансформации и написать его заново; даги остаются без изменений (уточнение владельца в комментарии к #148);
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а менти ищет причину и объясняет ее.
Существующие шесть заданий из `docs/labs/` входят в список как готовое ядро слоя DM. Идеи из тикетов #143, #145, #148, #149, #186, #187, #188 разнесены по подходящим участкам. В конце отдельно проверяется гипотеза «вычитание + DQ» для каждого потока нижних слоев.
## STG — прием и сырье
**1. Push против pull: два способа принять топик.**
Урок: способ приема зависит от источника. Поток постоянно читает подписанная матвью, а слепок забирает отдельный запрос по команде.
Материал: оба чтеца в `sql/ddl/10-stg-tables.sql` (у `orders_raw_kafka` нет ни матвью, ни `ON CLUSTER`, зато есть `kafka_commit_on_select`), матвью приема `sql/ddl/40-stg-views.sql`, забор `sql/stg/orders_raw_load.sql`, `dags/orders_ingest.py`; сравнение цены и наблюдаемости — заготовка спеки (`docs/specs/2026-07-30-stand-v2-realism.md`, раздел 12).
Форма: расследование двух живых путей на одном стенде.
Проверка: офсеты групп `clickstream_hits` и `clickstream_orders`, счет строк сырья по `_load_id`.
**2. Кто читал топик и куда легли данные.**
Урок: нода, которая читает топик, может меняться от прогона к прогону. Раскладка данных не меняется: ее определяет ключ шардирования — свойство самих данных.
Материал: колонка `consumer_host` в `stg.hits_raw_dist`; `docs/architecture/storage.md`, «Раскладка по шардам».
Форма: расследование данных.
Проверка: пересчет ключа шардирования по `hostName()` — строк на чужом шарде нет.
**3. Вторая consumer group — дубли.**
Урок: на кластер нужна одна группа потребителей. Если завести вторую, каждая нода получит полную копию топика.
Материал: `kafka_group_name` в `sql/ddl/10-stg-tables.sql`; сцена названа спекой (раздел 12).
Форма: расследование-опыт «сломай — увидь — верни» на переигранном дне.
Проверка: счет событий против описи мира (`make check-clickhouse`); уборка — снятием партиции `_load_ts` (правило из `docs/architecture/testing.md`).
**4. Дырка в офсетах.**
Урок: сырье может сохранить не все сообщения, которые пришли из источника. Пустое сообщение или зазор после пересоздания матвью видны только по разрыву в `kafka_offset`, поэтому пропажу не нужно искать глазами.
Материал: `docs/architecture/storage.md`, «Что проверено» (опыты 5 и 7 августа); колонка `kafka_offset` в сырье.
Форма: расследование.
Проверка: непрерывность офсетов внутри партиции.
**5. Две оси времени.**
Урок: переобработка данных и очистка диска живут по разным часам. Сырье разбивается на части и удаляется по `_load_ts`, а модельный день появляется только в ODS. Поэтому TTL по модельной дате работал бы неверно.
Материал: `sql/ddl/10-stg-tables.sql` (TTL трое суток, `ttl_only_drop_parts`), `docs/architecture/storage.md`, «Срок жизни сырья».
Форма: расследование-вопрос (почему нельзя нарезать сырье по `EventDate`).
Проверка: ответ сверяется с доводом доки; счетного инварианта нет.
## ODS — типизация и брак
**6. Предикат и его буквальное отрицание.**
Урок: годные строки и брак должны делить поток без пропусков и пересечений. NULL в предикате незаметно теряет строку, а исключение роняет вставку и останавливает чтение топика.
Материал: `sql/ddl/30-ods-views.sql` (пара матвью событий), `sql/ods/order_parse.sql` (макрос `order_wire_contract`), ADR 0005.
Форма: расследование — подложить в топик по строке каждого класса брака и найти их в `ods.event_errors_dist` / `ods.order_errors_dist`.
Проверка: «сырье среза = годные + брак» на управляемой пачке; строки опыта убрать за собой.
**7. Три времени и версия-наблюдение.**
Урок: у строки заказа есть четыре временные координаты. Версию сущности задает дата слепка источника, а не время загрузки. `FINAL` живет ровно в одном месте — за представлением `_v`.
Материал: `sql/ddl/20-ods-tables.sql`, `sql/ddl/30-ods-views.sql`, ADR 0010 и 0014, `docs/architecture/orders/ingestion.md` («Роль ODS»).
Форма: расследование — физические версии `ods.order_dist` против `ods.order_v`.
Проверка: эталон `argMax` по `updated_at` прямо из сырья (прием #165).
**8. Часовой пояс как способ смотреть на время.**
Урок: у ночных событий `toDate(UTCEventTime)` может не совпадать с `EventDate`. Это нормальное свойство данных. День нужно считать по готовой колонке и явно называть часовой пояс, в котором его рассматривают.
Материал: `docs/architecture/storage.md`, «Часовые пояса»; ADR 0015.
Форма: расследование данных.
Проверка: менти сам находит ночные события и объясняет расхождение.
**9. Сырое против разобранного: `ecommerce`.**
Урок: типизированный разбор JSON может молча подменить ошибку значением. Например, `qty` со значением «oops» превратится в 0. Поэтому правильность разбора нужно подтверждать инвариантом источника.
Материал: колонка `ecommerce` рядом с массивами `purchase_*` / `product_*` в `ods.event`; разбор `items` в `sql/dds/order_rebuild.sql`; спека, раздел 1.2 («кандидат будущей лабы»).
Форма: построить свое по ТЗ — разобрать `ecommerce` запросом и сверить с готовыми массивами того же события.
Проверка: построчная сверка с колонками-массивами.
## DDS — модель
**10. Лаба сессий: своя нарезка против эталона.**
Урок: сессия — расчетная сущность, границы которой определяет логика в хранилище. Самостоятельная нарезка может разойтись с эталоном. Если расхождение невозможно, задание сводится к `GROUP BY` по готовому полю.
Материал: `docs/architecture/dds/session.md`, `sql/dds/session_rebuild.sql` (два окна, полный порядок `(event_time, event_id)`, детерминированный чекан), эталон `VisitID` в `ods.event_v`.
Форма: построить свое — менти собирает нарезку сам до знакомства со стендовым кодом; либо вычитание `session_rebuild.sql` (раздел о гипотезе ниже).
Проверка: `dq_check` / `sessions_vs_reference` (зерно кука × день против `uniqExact(VisitID)`); затем сравнение со стендовой нарезкой по `session_id`.
**11. Поштучный разбор расхождения нарезки.**
Урок: счетная сверка показывает, сколько сессий разошлось. Чтобы понять, где именно отличаются границы, нужно вручную соединить свою сессию с эталонной по времени.
Материал: `sql/dq/sessions_vs_reference.sql`, `docs/architecture/dds/session.md`, «Сверка с эталоном» («поштучное сопоставление границ — материал лабы»).
Форма: расследование.
Проверка: найденная кука, день и пауза, на которой решения разошлись.
**12. Склейка идентичностей во времени (#145).**
Урок: текущее знание о пользователе и знание на выбранную дату дают разные отчеты. Если использовать сведения из будущего, исторический график известной аудитории изменится.
Материал: `dds.identity_map_v` (пары без временной колонки — решение `docs/architecture/dds/identity.md`), момент связи выводится как минимум времени `purchase`-события, соединенного с заказом.
Форма: построить свое (запрос, без изменения стенда).
Проверка: два способа на одном наборе данных; расхождение объясняется письменно.
**13. Справочник регионов (#143).**
Урок: справочник содержит данные, которыми источник владеет вне хранилища. Если собрать словарь руками, можно разобраться в механике, которую готовый каталог стенда позволяет только использовать.
Материал: образец `sql/ddl/05-dic-catalog.sql` и ADR 0012; зацепка в контракте — `region_city_id` / `region_country_id` отдаются как есть (`dds.event_v`).
Форма: построить свое по образцу (свой CSV, подложка, словарь, `dictGet` в отчете).
Проверка: обогащенный отчет; неатомарность обновления словаря на двух нодах — дополнительное наблюдение (спека, раздел 12).
**14. Инвариант позиций: недостающая проверка.**
Урок: равенство «цена × количество = items_total» проверяет, не исказил ли молчаливый разбор данные. Сейчас это равенство проверяют вручную, хотя ему нужно постоянное место в контуре DQ.
Материал: комментарий в `sql/dds/order_rebuild.sql` («постоянное место такой проверки — контур DQ этапа 4; пока она делается руками»), образцы `sql/dq/*_vs_dds.sql`, `dags/dq_check.py`.
Форма: построить свое — новая проверка по образцу существующих пяти.
Проверка: проверка обязана уметь краснеть — испортить позицию руками и увидеть красное (`docs/architecture/testing.md`).
**15. Сверка слоя с источником по дню.**
Урок: трансформация выполняет заданное правило, но сама не замечает, что часть данных не приехала. Это обнаруживает отдельная сверка слоя с источником.
Материал: `docs/architecture/etl.md`, «Чего конвейер о себе не знает» (три слепых пятна и лекарство: счет заказов и поздний `updated_at` с обеих сторон).
Форма: построить свое — вторая самодельная DQ-проверка.
Проверка: снести часть дня в ODS-окне — проверка краснеет, конвейер сам не замечал.
## DM — витрины
**16. Пять домашек DM (готовое ядро).**
Урок: готовые ТЗ уже содержат пять отдельных уроков (`docs/labs/README.md`): `events_enriched_v` показывает, что слой задает контракт, а не копирует данные; `top_pages_daily` меняет зерно, материализует витрину и включает ее в конвейер; `session_overview` учит хранить слагаемые вместо средних и не складывать уникальные значения, даже если на текущих данных результат совпадает; `dq_errors_daily` показывает, что у брака есть собственный день и срок жизни, а витрина может пережить источник; `utm_effectiveness` объясняет, зачем в имени нужно предупреждение `declared_`.
Материал: пять готовых ТЗ в `docs/labs/README.md`.
Форма: построить свое по ТЗ.
Проверка: `dags/homework_check.py` + `sql/dq/*_vs_dds.sql` (полное внешнее соединение с независимым ответом).
**17. Свой дашборд в Superset (готовая необязательная лаба).**
Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются.
Материал: `docs/labs/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()` должен входить в ТЗ каждого вычитания отдельной строкой.
+310 -142
View File
@@ -99,7 +99,7 @@
| `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования |
| `CounterID` | UInt32 | константа стенда (один сайт) |
| `EventDate` | Date | дата события |
| `UTCEventTime` | DateTime | единственная метка времени, как у Метрики |
| `UTCEventTime` | DateTime('UTC') | единственная метка времени, как у Метрики |
| `ClientTimeZone` | Int16 | смещение пояса клиента в минутах |
| `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` |
| `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) |
@@ -138,8 +138,9 @@ Ecommerce (заполнены только у торговых событий):
| `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) |
`add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав
заказа и блок `purchase*`. Выручка у клиента — во Float64, как у Метрики:
это не недосмотр, а часть урока о расхождениях (см. раздел 4).
заказа и блок `purchase*`. Выручка приходит JSON-числом Float64, как у
Метрики. ODS один раз нормализует деньги в `Array(Decimal(18, 2))`, не меняя
формат источника; DDS наследует тип хранения без нового вычисления.
### 1.3 Ключи и движки
@@ -172,8 +173,9 @@ Ecommerce (заполнены только у торговых событий):
рендеренное «описание выгрузки» в доках — аналог документации Метрики.
Сторона хранилища (DDL, SELECT матвью, трансформации, витрины) пишется по
этой документации на своих этапах, как в бою хранилище адаптируется к
источнику; границу сторожат строгий приём (раздел 6) и contract-тест в
smoke — сравнение `system.columns` поднятого стенда со схемой генератора.
источнику; границу сторожит строгий приём (раздел 6). Вторым сторожем здесь
стояла сверка объявлений — `system.columns` поднятого стенда против схемы
генератора; она снята при исполнении #43 как ничего не добавляющая к соседу.
Без контракта 47 колонок, повторяясь примерно в семи местах, расходятся
молча. Заодно это учебный артефакт: менти видит на живом примере, что
такое data contract.
@@ -189,44 +191,65 @@ smoke — сравнение `system.columns` поднятого стенда с
выручка дня D «дышит» K дней, потом замерзает. Боевой аналог окна есть и у
трекеров: лог Метрики «доформировывается» ещё около трёх дней.
- Запись слепка — состояние заказа на момент выгрузки, «родной» экспорт
бэкенда в snake_case:
бэкенда в snake_case. Таблица задаёт тип после разбора в ODS:
| Поле | Тип | Комментарий |
| Поле | Тип в ODS | Комментарий |
|---|---|---|
| `order_id` | String | номер заказа; равен клиентскому `purchaseID` |
| `user_id` | UInt64 | пользователь магазина — мост к склейке |
| `status` | String | `created` → `paid` → `cancelled` |
| `created_at`, `updated_at` | DateTime | |
| `status` | String | `created` → `paid` → `cancelled`; неоплаченный отменяется прямым переходом `created` → `cancelled` |
| `created_at`, `updated_at` | DateTime64(3, 'UTC') | аудит строки в БД источника: создание и последнее изменение; не бизнес-время покупки и не время загрузки в ClickHouse |
| `items_total`, `discount`, `delivery`, `total` | Decimal(18,2) | деньги бэкенда — в Decimal |
| `items` | String | позиции вложенным JSON: `[{sku, qty, price}]` |
| `snapshot_date` | Date | дата слепка (день выгрузки) |
| `items` | String | сырой текст массива позиций `[{sku, qty, price}]`, извлечённый из внешнего JSON |
| `snapshot_date` | Date | завершившийся модельный день, состояние которого снято на исходящей границе суток |
- Приём идемпотентный, но дедуп расщеплён на два слоя:
- `ods.order_snapshot` — партиция по `snapshot_date`, **без дедупа**,
хранит «как приехало»; идемпотентность повторного прогона — заменой
партиции дня слепка, а не ReplacingMergeTree.
- Дедуп до последней версии — **argMax** в трансформации при сборке
`dds.order`. `dds.order` — единственная дедуплицированная таблица:
партиция по дню заказа (`toDate(created_at)`),
ReplacingMergeTree(`updated_at`), `ORDER BY order_id` — заказ всегда
лежит в одной партиции, дедуп работает.
На проводе один заказ — один документ JSON. Деньги, включая `items[].price`,
передаются строками с ровно двумя знаками после точки; `created_at` и
`updated_at` — строками RFC 3339 в UTC с обязательными миллисекундами
(`YYYY-MM-DDTHH:mm:ss.SSSZ`); `snapshot_date` — строкой `YYYY-MM-DD`;
`items` — обычным массивом JSON, не строкой с JSON внутри. Порядок внешних
ключей совпадает с порядком полей в таблице контракта, у позиции — `sku`,
`qty`, `price`: так байты воспроизводимы без сортировки ключей. Разница с
кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами
Float64, а бэкенд передает деньги строками. На границе ODS оба источника
становятся `Decimal(18, 2)`: клиентское число разбирается сразу, без
промежуточного Float64 в хранилище.
Запись собирает явная функция существующего канонического сериализатора:
один словарь с вложенным списком и один `orjson.dumps`, без универсального
слоя кодеков.
Обоснование и проверка разбора — в
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
- `ods.order` принимает версии заказа в
`ReplacingMergeTree(snapshot_date)`: `ORDER BY order_id`, партиция по дню
неизменного `created_at`, шардирование по `cityHash64(order_id)`. Дата
слепка служит колонкой версии: выживает строка с максимальной
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)).
Дата не доказывает полноту принятой порции, поэтому ODS не заменяет партицию
слепка целиком. Физическое чтение может видеть несколько версий;
`ods.order_v` возвращает точное текущее состояние. В DDS одна строка хранит
один заказ с позициями в типизированных массивах; физическая модель и
пакетное наполнение описаны в [доке заказа](../architecture/dds/order.md).
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
- Разбор JSON-позиций — **один раз**, в трансформации ODS → DDS; дальше
витрины работают с плоскими массивами `dds.order`: `item_sku`
Array(String), `item_qty` Array(UInt64), `item_price` Array(Decimal(18,2))
— одной длины, порядок как в JSON. Это единственный носитель навыка
«вложенный JSON в ClickHouse» на стенде.
- `items` остаётся сырой строкой JSON в ODS. Позиции разбираются на границе
ODS → DDS в типизированные массивы строки заказа. Это остаётся носителем
навыка «вложенный JSON в ClickHouse», не превращая приём в преждевременную
модель данных.
- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания»
выручки внутри окна; сужение до двух — резервный срез 1.
выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна
заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда
`created`» не бывает ([судьба заказа](../architecture/orders/fate.md)).
## 3. Каталог товаров
CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`,
`brand`, `price`, `demand`) — **словарь ClickHouse** из файла. Тот же файл
использует генератор — расхождений нет по построению. Даёт `dictGet` в
витринах и разговор о политике обновления словаря. На кластере файл
монтируется в обе ноды, словарь создаётся ON CLUSTER. Последняя колонка —
`brand`, `price`, `demand`) — **словарь ClickHouse**. Тот же файл использует
генератор — расхождений нет по построению. Даёт `dictGet` в витринах и
разговор о политике обновления словаря. На кластере файл монтируется в обе
ноды; над ним стоит подложка, а словарь читает её и живёт в зоне справочников
`dic` вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)).
Последняя колонка —
уровень спроса товара, заведена при исполнении #50 (спека генератора,
раздел 9): генератор решает по ней, что уходит из карточки в корзину.
@@ -241,9 +264,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
| | Расхождение | Механика в генераторе | Ориентир доли |
|---|---|---|---|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов |
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого, и создаёт его генератор намеренно: деньги считаются целыми копейками, поэтому Float64 сам по себе не плывёт | ~1–2% |
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
| B | Потерянное событие | заказ есть, `purchase` не доехал | бросок 3%, наблюдаемых ~1,8%: часть маячков защищена парами склейки, часть уходит в отмену приоритетом |
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% |
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
@@ -251,9 +274,10 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
манифест хранит точные счётчики по каждому классу расхождений (отмены,
потери, дубли).
ориентир ~10%. Точные доли зафиксированы замером на каноническом мире
(28 дней, 2026-08-30, #194) — запись в «Что проверено»
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
только по дням с закрытым окном (раздел 8).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
механически дублирует B.
@@ -263,7 +287,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
слово `declared` в имени — сигнал «это заявка клиента, не деньги
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
раскопает почему (Float64 против Decimal, промокод, доставка, отмены).
раскопает почему (состав позиций, промокод, доставка, отмены).
## 5. Анонимность и склейка идентичностей
@@ -273,20 +297,28 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
- **Карта соответствий кука↔пользователь** строится трансформацией из уже
существующего моста: `purchase`-событие (`ClientID`, `purchaseID`) ↔ заказ
(`order_id`, `user_id`). Ни новых полей, ни нового транспорта.
- Форма и место карты: таблица `dds.identity_map`
(`client_id` UInt64, `user_id` UInt64, `first_matched_at` DateTime),
ReplacingMergeTree, `ORDER BY (client_id, user_id)`, шардирование по
`cityHash64(client_id)` — тем же выражением, что события (иначе ко-локации
нет); ко-локация делает обогащение витрин локальным;
заказы при сборке карты подтягиваются через GLOBAL JOIN.
- Форма и место карты: `dds.identity_map_rep` хранит пару `client_id` UInt64,
`user_id` UInt64 и координаты собственной загрузки `_load_id`, `_load_ts` в
`ReplicatedReplacingMergeTree(_load_ts)` с `ORDER BY (client_id, user_id)`.
`dds.identity_map_dist` шардирует по `cityHash64(client_id)` — тем же
выражением, что события; ко-локация позволяет обогащать витрины локально.
Публичный интерфейс `dds.identity_map_v` перечисляет колонки явно и прячет
`FINAL`. `first_matched_at` в стенде нет: у него нет читателя; история
склейки оставлена самостоятельной работе в issue #145.
- Карта пересобирается пакетно после модели заказов: все `purchase` из
`dds.event_v` соединяются с текущими заказами `dds.order_v` через
`GLOBAL JOIN`, статус заказа связь покупателя с кукой не отменяет. Каждый
запуск пишет полный текущий набор различных пар; предыдущий результат не
служит источником следующего.
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
параметр мира; значение фиксирует эта спека: 15%, детерминировано
от seed. Ядро лабы:
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
Константа мира: каждый двухкуковый покупатель делает минимум по одному
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
(она строится только из покупок) и лаба не воспроизводится. Манифест
хранит число именно таких пар.
(она строится только из покупок) и лаба не воспроизводится. Число таких
пар растёт, пока мир едет, и в опись не кладётся
([что хранит опись](../architecture/orders/inventory.md)).
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
@@ -306,35 +338,66 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
- Роли нод: нода 1 — инициатор DDL и подключение Airflow; **Superset — на
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
VIEW поверх локальной таблицы проявляются в дашборде сами.
- **Приём Kafka**: Kafka-таблицы и MV — на обеих нодах, одна consumer group,
2 партиции на топик; MV пишут в Distributed-цели. Раскладку решает ключ:
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели.
У заказов приём другого рода — пакетный забор без MV, чтец на одной ноде,
топик `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
Раскладку в обоих случаях решает ключ:
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
`cityHash64(order_id)`, сырьё STG —
`cityHash64(сырой строки)`. Урок: «какая нода читала топик — меняется между
прогонами, куда легли данные — нет».
- **Приём строгий**: `input_format_skip_unknown_fields = 0`, обязательные
поля — без значений по умолчанию. Контракт присутствия: генератор выдаёт
`cityHash64(order_id)`, сырьё STG — `cityHash64(сырой строки)`; полный
список и доводы — в [доке хранилища](../architecture/storage.md). Урок
«какая нода читала топик — меняется между прогонами, куда легли данные —
нет» живёт на событиях: при пакетном заборе читает та нода, которую
спросили.
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
сообщения сверяется с контрактным; строка с NULL среди опорных колонок
или с разошедшимся набором ключей уходит в `*_errors`. Опорными выбраны те,
чья порча отравляет всё ниже по течению: идентификаторы события, визита и
посетителя, дата партиции и метка времени, по которой события упорядочиваются
внутри сессии. `CounterID` формально тоже в ключе сортировки, но на стенде он
константа, и NULL там взяться неоткуда. Остальные сорок две
достаются обычными типами — сорок семь проверок на NULL превратили бы матвью в
простыню, а присутствие и так целиком закрыто сверкой ключей. Сверка ключей —
не добавка: у массивов NULL не бывает, и пропавшее поле-массив иначе
неотличимо от пустого по смыслу. На входе разбора нет вовсе — Kafka-таблица
читает сообщение байтами, строгость целиком в матвью ODS
([ADR 0005](../adr/0005-event-ingestion.md)). Контракт присутствия: генератор выдаёт
**все 47 полей в каждом событии**; «пусто» — пустой массив, пустая строка
или 0, а не отсутствие ключа в JSON. Так строгий приём уживается с
полями, пустыми по смыслу (ecommerce у `pageview`, UTM у прямого захода). Несовпадение имени поля — громкая
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
опечатка иначе не падает.
- **Политика соединений**: по ключу ко-локации — обычное соединение с
комментарием, почему локальный результат корректен; по любому другому ключу
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка
комментарием, почему локальный результат корректен; если публичные
представления скрывают ко-локацию — решение записывается рядом с витриной.
По любому другому ключу — только явный GLOBAL; `NOT IN` — только
`GLOBAL NOT IN`. Сверка
`purchase`↔заказ —
легитимная GLOBAL-витрина (заказы малы).
- **Конвейер без TRUNCATE**: поток — append-only в ReplacingMergeTree (дедуп
через argMax); батчевая переобработка — по дневным партициям
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` остаётся
только в `make reset`. `DROP/REPLACE PARTITION` работает только по
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
осознанная цена, не баг).
- **Поздние заказы поглощает только ODS** (`ods.order_snapshot` — новая
партиция дня слепка, без переделки старого); материализованное ниже —
нет. Каждый прогон ETL перестраивает партиции последних K+1 дней у
заказозависимых объектов (`dds.order` и производные, `dm.dq_summary`).
- **Конвейер без TRUNCATE**: поток версий — в ReplacingMergeTree, точное чтение
— через `FINAL` или равносильный выбор последней версии; батчевая
переобработка нижележащих объектов — по дневным партициям
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
применяется вовсе: обычная переобработка ограничена партицией, а полное
восстановление данных — базами слоёв в следующем пункте. `DROP/REPLACE
PARTITION` работает только по **локальным** таблицам ON CLUSTER, не по
Distributed; замена через DROP+INSERT неатомарна — дашборд в середине прогона
честно моргает (это осознанная цена, не баг).
- **Область очистки следует за областью состояния**: обычная разработка не
требует пустых томов. Сначала пересоздаётся затронутый объект, партиция или
база слоя; если нужен целиком чистый мир, пересоздаются прикладные базы
ClickHouse, топики и позиции их потребителей Kafka, затем заново заливаются
данные. Сами службы и их тома сохраняются. `make clean` нужен, только когда
требуется удалить состояние вне прикладного мира либо проверить запуск именно
с пустых томов. Перед таким сбросом агент называет это состояние и объясняет,
почему более узкой очистки недостаточно. «Так проще и надёжнее» не считается
обоснованием. Обязательный `make clean` перед каждой приёмкой отвергнут: он
стирает следы неисправности и превращает редкий холодный запуск в постоянную
цену разработки.
- **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их
подхватывают материализованные объекты DDS и DM, решается вместе с их моделью,
а не при проектировании приёма.
Сессии перестраиваются только за текущий день: правило мира — сессия
режется по границе модельных суток, дневная партиция самодостаточна.
- Для ETL-вставок — `distributed_foreground_insert = 1` (раньше называлась
@@ -366,30 +429,55 @@ README.
## 7. Слои: карта таблиц v2
| Слой | Объект | Что это |
| Где | Объект | Что это |
|---|---|---|
| Kafka | `hits`, `orders` | два топика, по 2 партиции |
| STG | `stg.kafka_hits`, `stg.hits_raw` + MV; то же для orders | сырые строки, Kafka Engine на обеих нодах |
| ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree |
| ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа |
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
| DDS | `dds.v_event` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
| DDS | `dds.order` | единственная дедуплицированная таблица заказа: партиция по дню заказа (`toDate(created_at)`), ReplacingMergeTree(`updated_at`), `ORDER BY order_id`, дедуп до последней версии — argMax в трансформации при сборке |
| DDS | `dds.identity_map` | карта кука↔пользователь |
| DDS | словарь `products` | каталог из CSV |
| DM | витрины `dm.v_*`, `dm.dq_summary` | см. ниже |
| Kafka | `hits`, `orders` | два топика: `hits` — 2 партиции, `orders` — одна |
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах |
| STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг |
| ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние |
| ODS | `ods.order` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние |
| DDS | `dds.session_rep` / `_dist`, `dds.session_v` | физическая сборка сессий и её публичный интерфейс |
| DDS | `dds.event_v` | представление над `ods.event_v`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
| DDS | `dds.order_rep` / `_dist`, `dds.order_v` | текущая модель заказа и её публичный интерфейс |
| DDS | `dds.identity_map_rep` / `_dist`, `dds.identity_map_v` | физическая карта кука↔пользователь и её точный публичный интерфейс |
| DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) |
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
Служебные колонки: `ods.event` и `ods.order_snapshot` получают метку приёма
`_ingested_at`; у `ods.event` та же колонка — колонка версии
ReplacingMergeTree. Таблицы `stg.*_raw` хранят виртуальные колонки Kafka
(`_topic`, `_partition`, `_offset`, `_timestamp`) — без них урок «какая нода
читала топик» ненаблюдаем. `stg.hits_raw` дополнительно хранит извлечённый
`event_date` — им кормится переобработка дня X (при исчерпании retention
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
см. [доку хранилища](../architecture/storage.md).
Потребительские запросы и витрины читают сущности DDS через `_v` с явным
списком колонок; `_rep` и `_dist` служат загрузке, диагностике и обслуживанию.
Договор держится конвенцией, а не правами: модель доступа описана в
[ADR 0007](../adr/0007-clickhouse-access.md), договор чтения — в
[доке хранилища](../architecture/storage.md), «Имена объектов».
Заказы принимаются **пакетным забором** ([ADR
0008](../adr/0008-order-ingestion.md), [ADR
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у событий, но
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
Один следующий `task_id` двумя последовательными запросами пишет годные строки
и ошибки из того же среза. `ods.order` выбирает текущую версию по максимальной
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Слой
сырья остаётся точкой повтора и разбора одной принятой порции.
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
заказа задаёт дата слепка; `_load_id` проходит через STG и обе цели ODS. Таблицы
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
без них урок «какая нода читала топик» ненаблюдаем.
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
разбирает ODS, где эта колонка и служит ключом партиции. Переобработка режется
по времени загрузки, координате самого слоя доставки. При исчерпании retention
Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
см. [спеку генератора](2026-08-01-generator.md)).
см. [спеку генератора](2026-08-01-generator.md).
`dds.v_event` — первый на стенде пример правила «слой — это контракт, а не
обязательно копия данных».
`ods.event_v` сохраняет имена, типы и зерно ODS, но скрывает `FINAL` и
возвращает актуальную версию события. `dds.event_v` — другой контракт: оно
переводит событие на язык бизнес-модели, в том числе даёт snake_case-имена и
расшифровывает коды. Это первый на стенде пример правила «слой — это контракт,
а не обязательно копия данных».
Событие в DDS не дублируется: склейки четырёх источников больше нет, ODS уже
широкий и типизированный; DDS хранит бизнес-сущности (сессия, заказ,
@@ -398,35 +486,43 @@ Kafka день переигрывается генератором заново:
### Витрины DM
- **`v_revenue_daily`** (выручка, только от заказов): `report_date`,
- **`revenue_daily_v`** (выручка, только от заказов): `report_date`,
`product_category` (через `dictGet` каталога + ARRAY JOIN позиций),
`orders`, `units`, `revenue`, `aov`. Считается по заказам в статусе
`paid`; внутри окна K число дня «дышит».
- **`v_purchase_vs_orders`** (сверка): FULL OUTER GLOBAL JOIN по
- **`purchase_vs_orders_v`** (сверка): FULL OUTER GLOBAL JOIN по
`purchaseID = order_id`; колонки: `order_day`, `order_id`,
`declared_revenue` (клиент), `items_total` (бэкенд, сравнимая база — не
`total`: промокод и доставка клиенту не видны), `status`, `mismatch_class`
(`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`,
в порядке приоритета — классы пересекаются, побеждает более ранний).
`match` — большинство строк; `amount_delta` — только необъяснённый остаток
после приведения к сравнимой базе; его создаёт генератор намеренно
`match` — большинство строк; `amount_delta` — остаток, не объяснённый
приведением к сравнимой базе: сравнением позиций он объясняется, и на этом
стоит урок класса C; создаёт его генератор намеренно
(~1–2% заказов, см. раздел 4).
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
слепка, а не расхождение: она получает служебный класс `awaiting_order`
(шестое значение `mismatch_class`, вне приоритетов расхождений). После
закрытия окна K таких строк не остаётся — сироты исключены построением
(раздел 4).
- **`v_utm_effectiveness`** — остаётся клиентской (атрибуция по трекеру);
- **`utm_effectiveness_v`** — остаётся клиентской (атрибуция по трекеру);
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
`declared_revenue` по UTM.
- **`v_daily_traffic`** — расширяется парой «посетители» / «известные
пользователи» (обогащение через `dds.identity_map`, локальное соединение
по ключу ко-локации).
- `v_events_enriched`, `v_top_pages_daily`, `v_session_overview`,
`v_dq_errors_daily` — переезжают на новую модель без смены роли: источник —
`dds.v_event`, не `ods.event`.
- `dm.dq_summary` переводится с TRUNCATE+INSERT на партиционную замену
(политика «без TRUNCATE»).
- **`daily_traffic_v`** — расширяется парой «посетители» / «известные
пользователи». Обогащение идет явным `GLOBAL LEFT JOIN` через
`dds.identity_map_v`: опыт на ClickHouse 26.3 показал, что публичные `_v`
скрывают ко-локацию от локальной подстановки.
- `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`,
`dq_errors_daily_v` — переезжают на новую модель без смены роли. Источник
и зерно у двух из них меняет сама новая модель, и это зафиксировано ТЗ
домашек ([`docs/labs/`](../labs/README.md)):
`session_overview_v` читает `dds.session_v` и поднимается на зерно «день ×
срез», потому что визит стал сущностью DDS и витрина его зерна была бы
копией сессий; `dq_errors_daily_v` читает таблицы `ods.*_errors`, у
которых в DDS представительства нет. Остальные две читают `dds.event_v`,
а не `ods.event`.
- `dm.dq_summary` хранит текущее качество дня и обновляется партиционной
заменой (политика «без TRUNCATE»).
Дашборд Superset получает три новых сюжета: выручка по дням и категориям,
таблица сверки с классами расхождений, пара посетители/известные.
@@ -435,26 +531,34 @@ Kafka день переигрывается генератором заново:
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
ландшафта выражена режимами и частотами, а не второй трубой.
## 8. Эталонный мир и манифест
## 8. Эталонный мир и опись
В git хранится только манифест эталонного мира; сам снимок (14 модельных
дней) генерируется на месте — при `make up` и при проверках (решение
развилки «Производительность» карты #26, подробности —
[спека генератора](2026-08-01-generator.md), раздел 5). Манифест несёт
В git хранится только опись эталонного мира; сам снимок генерируется на
месте — при `make up` и при проверках (решение развилки «Производительность»
карты #26, подробности — [спека генератора](2026-08-01-generator.md),
раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней
снято на этапе 7. Опись несёт
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман
карты #10) закрыта этим же ходом: версионируется манифест.
Контрольные числа манифеста:
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
карты #10) закрыта этим же ходом: версионируется опись.
Контрольные числа описи:
- заказная сторона: заказы и выручка по дням; манифест хранит точные
счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) —
самопроверка лабы сверки;
- идентичность: uniq кук, uniq известных пользователей, число двухкуковых
покупателей — лаба склейки получает самопроверку.
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями;
- заказная сторона: счётчики наблюдаемых классов расхождений после
приоритета; каждый показывает число заказов и хранится только для дня с
закрытым окном (читатель — техническая проверка `classes_vs_inventory`
звена DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для
общего числа заказов и выручки в описи пока нет: для них не назван
читатель;
- контрольные числа идентичности не заводятся: uniq известных пользователей
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
названного читателя в опись не кладётся
([что хранит опись](../architecture/orders/inventory.md)).
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
при пересборке.
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
[карты проверок](../architecture/testing.md).
## 9. Оценка объёма исполнения
@@ -465,10 +569,10 @@ v2 стартует пустым, поэтому объём ниже — это
|---|---|---|
| Генератор | с нуля: модель v1 не переносится (другая модель данных, плюс известные проблемы производительности v1); широкое событие, таксономия, анонимность, N:1, заказы слепками, расхождения A–D, каталог; масштаб — ~4–5 тыс. строк с тестами | L |
| Инфраструктура | compose: 2 ноды CH + keeper + остальной стенд; конфиги кластера, макросы; make/скрипты | M — ~10–12 файлов |
| SQL | 5 DDL-файлов (ON CLUSTER, Replicated*, Distributed) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets), world_init/next_day, helpers | M — ~5–6 файлов |
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов |
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
| Эталонный мир | пересборка снимка на месте, манифест-счётчики, чек-скрипты | M–L |
| Эталонный мир | пересборка снимка на месте, счётчики описи, чек-скрипты | M–L |
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
@@ -487,7 +591,7 @@ v2 стартует пустым, поэтому объём ниже — это
`cancelled`) — на статусе `paid` стоит «дыхание» выручки; сужение до пары
`created`/`cancelled` — запасной ход, если генератор заказов окажется дороже
ожиданий. Связка: если срез 1 сработает, определение выручки в
`v_revenue_daily` придётся сменить с «заказы в статусе `paid`» на «все
`revenue_daily_v` придётся сменить с «заказы в статусе `paid`» на «все
неотменённые заказы».
- **Отступление от порядка #15**: резолюция предписывала резать в порядке
1 → 2 → 3, спека применяет 3 и 2, а 1 держит в резерве. Довод: срезы 3 и 2
@@ -505,24 +609,48 @@ v2 стартует пустым, поэтому объём ниже — это
2. DDL и генератор (слиты в один этап — DDL проверяется только настоящими
данными): базы и таблицы событий ON CLUSTER, приём `hits` обеими нодами;
широкое событие, таксономия, анонимность, N:1 (клиентская сторона
целиком). В конце этапа фиксируется маленький «зерновой» мир для
целиком). В конце этапа фиксируется маленький стартовый мир для
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
отдельный этап 7).
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного
батча заказов — сенсор/Datasets).
6. Расхождения B+D и опоздания; счётчики манифеста.
7. Эталонный мир: манифест и пересборка снимка, чек-скрипты; CI-генерация
на amd64 и arm64.
3. Заказы и каталог: генератор слепков, STG/ODS заказа, словарь и даг приёма
слепка ([ADR 0008](../adr/0008-order-ingestion.md)). Модель заказа в DDS
уехала этапу 4 — у служебной колонки и зерна читатель появляется там
(нарезка этапа, #89).
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
выручка, сверка A+C.
5. Airflow: `etl_pipeline` (партиционная переобработка).
6. Расхождения B+D и опоздания; счётчики описи.
7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация
снята (спека генератора, раздел 5).
8. Superset-дашборд v2.
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
и границы — ADR 0002.
и границы — ADR 0002. Этап идёт не подряд, см. ниже.
Критерий приёмки этапа — честный: `make up` работает и проходят
smoke-проверки, а не «дашборд зелёный». Это минимальная планка; свои
наблюдаемые критерии каждый этап получает при разбиении в /to-tickets. Документация правится в PR этапа
(правило AGENTS.md).
**Мониторинг не ждёт своего номера.** Зависимость у дашбордов формально только
от этапа 1, а «в хвосте» было выбором, не необходимостью. Довод за переезд
пришёл со второй стороны: дашборд — точка наблюдения не только для менти, но и
для владельца на приёмке этапов 4–8, а шесть вопросов дашборда «Данные» из
ADR 0002 с приёмочными совпадают почти целиком. Поэтому дашборды строятся
параллельно этапам 4–8. Состав от переезда не меняется, меняется срок.
В хвосте остаётся то, чему нужен прожитый опыт или дозревший мир: runbook
«keeper упал / DDL повис в очереди», `system.text_log` (ADR 0002 привязал его
к runbook), правила оповещения и урок «посмотрели → сломали → увидели →
вернули» (раздел 10).
Порогов панели не несут вовсе, и это правило, а не срок: на дашборд смотрят
глазами; красное и зелёное живёт в проверках — у целей `make` про стенд и у
дагов качества данных про данные.
Что гонять на приёмке этапа — строка в [карте
проверок](../architecture/testing.md), раздел «Какую проверку когда
запускать»: на стенде зелены все три цели, которым он нужен, а состояние перед
проверкой восстанавливается в самой узкой достаточной области. Запуск с пустых
томов обязателен только для изменения начальной сборки или постоянного состояния
служб, а не как общая гигиена этапа. Эти цели обещают, что этап не сломал стенд,
и молчат о его предмете — дашборд может быть не нарисован, а все три останутся
зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в
/to-tickets. Документация правится в PR этапа (правило AGENTS.md).
## 10. Границы: чего не делаем
@@ -530,11 +658,19 @@ smoke-проверки, а не «дашборд зелёный». Это мин
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
переносится. Спека даёт будущим лабам только опорные точки — контрольные
числа манифеста (сверка, идентичность). Явное следствие: после этапа 9
стенд работает, но учебного пути на нём ещё нет.
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и
конвейер, в который она встает, проектируются вместе. Сюда же —
необязательная лаба «свой дашборд в Superset» (#197): она стоит на
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как
остальные лабы пока на уровне идей. Единый стиль блока
домашек — за работой учебного пути. Спека даёт будущим лабам только
опорные точки — контрольные числа описи (сверка, идентичность). Явное
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет.
- Инкрементальный ETL (#8) — свой issue.
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
записи, квоты, политики строк и аудит запросов — за границей учебного стенда.
- Полный словарь торговых событий Метрики (detail, remove, impressions),
пять уровней категорий, блоки `purchasedProduct*`/`impressions*`.
- Механика `Sign`/CollapsingMergeTree — кандидат на потом (дом — поток
@@ -551,18 +687,33 @@ smoke-проверки, а не «дашборд зелёный». Это мин
## 11. Проверить при исполнении
- Поведение соединения двух Distributed-таблиц и `distributed_product_mode` —
эмпирически на стенде (хвост #14).
- Kafka Engine на двух нодах в одной consumer group: ребаланс партиций между
прогонами, отсутствие дублей при штатной работе.
- Точная форма `ORDER BY` ODS-таблиц (выражение `intHash32` в ключе
ReplacingMergeTree).
Список убывает по мере постройки: проверенное уходит отсюда, а ответ с датой
остаётся там, где на него опираются. Формат чтеца и форма виртуальной метки
времени закрыты при исполнении #37; форма ключа ODS и поведение матвью над
`Distributed` — при исполнении #43, ответы в [доке
хранилища](../architecture/storage.md), раздел «Что проверено»; запасной
именованный кортеж — там же в [ADR 0005](../adr/0005-event-ingestion.md).
Kafka Engine на двух нодах снят с этого списка при приёмке этапа 2 (7 августа
2026 года) — проверенным наполовину и осознанно. Что обе ноды читают топик и
обе партиции доезжают, показал #37. Дубли и раскладку партиций между прогонами
решено не проверять: дубль здесь возможен по устройству движка, окно названо и
разобрано в [доке хранилища](../architecture/storage.md), а в ODS его схлопывает
`ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо
обезврежено, — работа без ответа на конце.
- Размер артефакта эталонного мира после пересборки.
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
сверять через MCP Context7 (правило AGENTS.md).
- Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и этапа 5 пишутся
под API третьей версии (Datasets → Assets) — актуальные операторы и сенсоры
проверить через Context7.
- Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и пишутся под API
третьей версии (Datasets → Assets) — актуальные операторы проверить через
Context7. Первый даг приходит этапом 3, а не 5 ([ADR
0008](../adr/0008-order-ingestion.md)).
- Пакетный забор слепка из Kafka: прямое чтение коммитит офсеты и возвращает
одну порцию. На стандартном мире около полутора тысяч заказов помещаются в
неё с запасом; это проверяемое допущение стенда, а не граница полноты слепка
([ADR 0008](../adr/0008-order-ingestion.md), [ADR
0010](../adr/0010-order-versions-in-ods.md)).
- Генератор: рабочее решение — Python с производительной архитектурой
(батчевая генерация вместо посточной, быстрая JSON-сериализация,
распараллеливание по модельным дням). Читаемость генератора для менти —
@@ -605,11 +756,28 @@ v2, этап 0).
- сцена «разные consumer groups → дубли»;
- словарь регионов из CSV той же машинерией, что каталог товаров (оживляет
`RegionCityID`);
- неатомарное обновление словаря на кластере: ноды перезагружают его в
случайный момент внутри окна `LIFETIME`, фазы у них независимы, и после
правки каталога две ноды почти всё окно отвечают на один `dictGet`
по-разному. Обвязка готова ([ADR 0012](../adr/0012-dictionary-home.md)),
урок остаётся на выбор;
- лаба сессий: менти сначала собирает сессии сам, и только после — рассказ,
что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично
синтетическая постановка — осознанный приём;
- лекция «`Sign` и CollapsingMergeTree»: почему на стенде `sum(Sign)` =
`count()`, а в бою — нет; частый вопрос на собеседованиях;
- два способа принять топик, рядом на одном стенде: **поток против слепка,
push против pull**. События тянет матвью на лету ([ADR
0005](../adr/0005-event-ingestion.md)), слепок заказов забирает пакетный шаг
по команде дага ([ADR 0008](../adr/0008-order-ingestion.md)); чтец в обоих
случаях байтовый, так что различает их ровно режим. Сравнение цены,
наблюдаемости и того, чем платит каждый режим, — как задание. Третий способ,
типизированный чтец с `kafka_handle_error_mode`, на стенде не живёт: он
отвергнут обоими ADR, и остаётся материалом для рассказа;
- матвью как рабочий механизм, а не диковина: события проходят из STG в ODS на
лету, заказы — пакетным заданием. `ods.order_v` показывает границу между
физическими версиями и точным текущим состоянием; сравнение `FINAL` с
альтернативными способами чтения остаётся материалом задания;
- лекция про идентичность «как в бою»: `setUserID` и first-party id,
детерминированная против вероятностной склейки, identity graph,
кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер
File diff suppressed because it is too large. Load diff
+264
View File
@@ -0,0 +1,264 @@
# Жизненный цикл мира: создание и пересоздание ведёт Airflow
Дата: 21 августа 2026 года. Решение и доводы — [ADR 0013](../adr/0013-world-lifecycle.md);
здесь устройство, которое из него следует.
## Задача
До этой работы начало оси мира собирали трое. Compose создавал топики, применял
DDL и двумя одноразовыми службами заливал восемь дней. Shell-сценарии ждали,
пока события доедут до ODS, и разово дёргали даг приёма заказов. Airflow вёл
мир после стартовой заливки.
Граница владения не совпадала с процессом, и это видно по следствиям. Успешная
отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу
после записи в топик, до приёма сообщений хранилищем.
Повторный `make up` смешивал подъём служб с повторной заливкой данных. Узкая
пересборка хранилища сохраняла в Kafka сообщения и офсеты прежнего мира, потому
что чинила только ClickHouse. Позиция пульта правилась снаружи Airflow — тем,
кто миром не управляет.
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
прикладная схема, данные мира и позиция модельного времени, — и Compose,
который держит только инфраструктуру. Зеленая ветка создания в
`world_initialize` подтверждает: схема создана, оба источника приняты,
следующий день известен, слои DDS и DM собраны.
## Решение
Граница владения одна: **Compose отвечает за инфраструктуру; Airflow — за
прикладной мир.**
Compose держит долгоживущие службы, сети, тома, объявленные файлом учётные
записи ClickHouse, инициализацию метаданных Airflow и инициализацию Superset.
Airflow держит прикладные топики Kafka, канонический DDL прикладных баз `stg`,
`ods`, `dds`, `dm`, `dic`, данные стартового мира, их приём и переменную
`world_position`.
Одноразовых служб `kafka-init`, `clickhouse-init`, `world-init` и
`world-snapshots` в графе Compose не остаётся. Ни профиля, ни вызова через
`compose run` им не оставляют: спрятанная за профилем служба — тот же второй
оркестратор, только менее заметный.
`make` инфраструктурой и ограничивается: поднимает стенд, зовёт нужный даг
через REST API Airflow и ждёт результата. Shell больше не применяет DDL, не
запускает генератор и не правит позицию.
### Учётная запись жизненного цикла
Прикладную структуру создаёт и удаляет отдельная учётная запись `lifecycle` с
ролью `lifecycle_owner`, объявленная тем же файлом, что остальные учётные записи
([ADR 0007](../adr/0007-clickhouse-access.md)). Ей разрешено администрировать
прикладные базы — заводить и удалять их и объекты внутри, — читать их и
выполнять кластерный DDL. Ей разрешено чтение `system.settings`: без него
драйвер `clickhouse-connect` под официальным провайдером Airflow не открывает
соединение. Административные запросы к системным базам ей запрещены.
`etl` при этом не расширяется. Право удаления баз нужно редкой операции
восстановления и остаётся у `lifecycle`. Граница проверяема: при лишнем запросе
менти увидит в ошибке имя учётной записи и недостающее право.
### Что Airflow получает от репозитория
Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`,
`data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого
собирается канонический образ генератора, из второго читается опись мира, а
последний не пускает в контекст сборки локальные окружения и кэши. Запросы
стенда приезжают прежним монтированием `sql/`.
Меняющиеся API сверены через Context7 и затем с установленными пакетами
стенда. Выбраны публичный REST API v2 и ждущий `TriggerDagRunOperator` Airflow,
сборка образа через `DockerHook.get_conn().build()`, futures `AdminClient` для
Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов ClickHouse и
`ClickHouseHook` для запросов, чей результат обрабатывает Python. Оператор сам
шаблонизирует файл и при `split_statements=True` исполняет выражения по порядку;
собственный разбор SQL дагу больше не нужен.
## Контракты дагов
Оба дага публичные: без расписания, не на паузе, `max_active_runs = 1`. Их
запускают руками из интерфейса и командой `make` через REST API.
### `world_initialize` — создать мир, если его нет
Даг безопасен по построению: на готовом мире он ничего не меняет.
1. Читает `world_position` и решает, что делать дальше: инициализировать,
успешно завершиться без изменений или покраснеть. Разбор состояний — в
таблице ниже.
2. Создаёт топики: `hits` с двумя партициями, `orders` с одной. Топики идут
**до** DDL: чтец ClickHouse начинает читать топик, когда над ним появляется
матвью, и топик, созданный брокером сам, приехал бы с одним разделом
([ADR 0008](../adr/0008-order-ingestion.md)).
3. Применяет канонический DDL из `sql/ddl/` по порядку имён файлов, с ноды 1,
учётной записью `lifecycle`. Каждый файл — отдельная последовательная
задача `SQLExecuteQueryOperator`; выражения внутри неё исполняются по
порядку с `split_statements=True`. Красная задача называет файл и не пускает
последующие. Правило «матвью приёма создаётся последней» остаётся за
раскладкой файлов и не переезжает в даг.
4. Собирает канонический образ генератора. Иначе мир мог бы приехать из старого
кода: сборкой образа при подъёме больше никто не занимается.
5. Играет стартовые дни одним прогоном `batch` того же образа.
6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего
сценария, и оно точное: сумма строк `ods.event_dist` за даты описи и строк
`ods.event_errors_dist` не меньше суммы событий из
[`data/world-inventory.json`](../../data/world-inventory.json). «Что-то
приехало» условием не считается.
7. Отправляет слепки заказов вторым прогоном образа — со сдвигом, дни 0…6
([заказы в стартовом мире](../architecture/orders/start-world.md)).
8. Дергает `orders_ingest` ждущим триггером и продолжает только после его
успеха.
9. Ставит позицию в `WORLD_STARTING_DAYS`: источники приняты, мир уже создан.
10. Последним шагом дергает `etl_pipeline` ждущим триггером. Конвейер
последовательно ждет `dds_transform` и `dm_transform`; отказ слоя красит
инициализацию.
Порядок задач связывает проверки: каждый шаг подтверждён предыдущим, а красная
задача называет место остановки. Ветка "мир уже существует" конвейер не
запускает: обычный повтор `make up` по-прежнему ничего не меняет.
### `world_recreate` — снести мир и создать заново
Сначала даг выполняет всю разрушительную часть, затем делегирует создание мира.
1. Ставит позицию в `-1`: прежний мир объявлен недействительным раньше, чем с
ним что-то произошло.
2. Удаляет прикладные базы ClickHouse `stg`, `dm`, `dds`, `ods`, `dic` — первым
делом, чтобы чтецы Kafka перестали существовать вместе с ними.
3. Удаляет позиции групп потребителей `clickstream_hits` и
`clickstream_orders`, затем топики `hits` и `orders`.
4. Ставит позицию в `0` — только после успешной очистки.
5. Дёргает `world_initialize` ждущим триггером и ждёт его результата.
**Общего модуля с шагами двух дагов нет.** Переиспользование здесь — это
делегирование: пересборка вызывает создание мира целиком. Такой модуль стал бы
третьим местом с той же последовательностью, а первое же расхождение между
дагами пришлось бы прятать в его параметрах.
### Сторожа у работников мира
`world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше
`WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг:
поднять стенд или пересобрать хранилище. Выключатель `world_live` своего
сторожа не получает — он краснеет вслед за работником.
`orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда
готового мира ещё нет.
## Состояния позиции
`world_position` остаётся единственным состоянием пульта.
| Позиция | `world_initialize` | `world_recreate` | Работники |
|---|---|---|---|
| переменной нет | создаёт мир | очищает и создаёт | красный отказ |
| `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ |
| `0` | создаёт мир | очищает и создаёт | красный отказ |
| `WORLD_STARTING_DAYS` и больше | мир уже есть: пишет это в журнал и зеленеет | очищает и создаёт | работают |
Значения между `1` и `WORLD_STARTING_DAYS` штатно не возникают: позиция
становится либо `-1`, либо `0`, либо номером сыгранного дня за стартовым миром.
Попав на такое значение, `world_initialize` краснеет тем же отказом, что на
`-1`, и называет прочитанное число.
## Поведение при отказах
**Пересборка упала на очистке.** Позиция остаётся `-1`. Обычный `make up` эту
поломку не заглаживает: `world_initialize` краснеет и говорит, что мир
недействителен и пересборку надо повторить. Стенд обязан назвать незавершённую
очистку вслух и не продолжать работу молча.
**Очистка прошла, создание остановилось до установки позиции.** Позиция
остаётся `0`. Инициализация детерминирована, поэтому следующий `make up` просто
пробует её заново — с того же места и с тем же результатом.
**ETL упал после установки позиции.** Мир и оба источника уже готовы, поэтому
позиция остается в `WORLD_STARTING_DAYS`. После исправления запускают рукой
`etl_pipeline`: он сам выводит объем из состояния данных. Повторный
`world_initialize` здесь не поможет: при готовой позиции инициализация уйдет в
короткую ветку без изменений.
**Позицию в `WORLD_STARTING_DAYS` ставит только успех обоих источников.**
События доехали, слепки приняты, `orders_ingest` зелёный — тогда и только тогда
мир считается созданным.
**Порядок очистки Kafka входит в контракт.** Пока живы чтецы
ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо.
Поэтому сначала удаляются базы, затем топики.
**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: сырьё
в STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на непустом
ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
делает себе сам и тут же видит.
**Слоты Airflow.** Их четыре. При пересборке `world_recreate` ждет
`world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик.
Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live`
на паузу и даёт работникам доиграть. Предусловие остаётся в документации;
механизм блокировок стоил бы дороже этой строки.
## Границы
- **`make up` не мигратор схемы.** На готовом мире изменённый DDL не
применяется. Обновляет схему явная пересборка `world_recreate` либо
адресная правка объекта в разработке. Размен принят с открытыми глазами.
- **Ни блокировок, ни статуса, ни автоматической паузы.** Предусловие
обслуживания названо словами.
- **`etl` не расширяется**, `orders_ingest` не получает сторожа.
- **`make clean` сохраняет свой смысл**: полное удаление постоянного состояния
стенда. Ни в пересборку, ни в обычную проверку он не входит.
- **`make generate-batch` и `make generate-live` остаются** — с них снимаются
унаследованные зависимости от исчезающих одноразовых служб.
- Содержание DDL, код генератора и устройство слоёв не меняются.
## Проверка
Цели `make` — обычные: `config-test` и `lint` без стенда, `smoke`,
`check-clickhouse` и `check-services` на поднятом. Сверх них проверяются
сценарии, которых у стенда раньше не было.
- **Повторный `make up` на живом мире** проходит быстро и ничего не меняет:
позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что
мир уже есть.
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы
заново, офсеты групп идут с нуля, DDS и DM собраны.
- **Красные пути проверяются своими отказами.** Сторож работника при
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
доказывает только то, что умеет печатать «ЗЕЛЁНО».
- **Отказ в правах читается.** Учётная запись `lifecycle` применяет весь DDL
без единой ошибки прав, а административный запрос к системной базе ей
отклоняют — и текст отказа называет учётку.
- **Один холодный `make clean && make up`** оправдан на итоговой приёмке и
только этой работой: она меняет самый первый подъём стенда, а его иначе не
увидеть. Обобщать этот случай не нужно — обычная проверка по-прежнему
обходится узкой областью очистки.
Замеры холодного и повторного `make up` и пересборки записываются в
[карту проверок](../architecture/testing.md), раздел «Что проверено».
## Отклонённые варианты
- **Спрятать одноразовые службы за профилем или звать их `compose run`.**
Дешевле всего в правках, но оставляет двух оркестраторов одного процесса —
ровно то, ради чего затевался переезд.
- **Оставить применение DDL и заливку мира в shell, а Airflow отдать только
позицию.** Тогда «один наблюдаемый процесс» остаётся обещанием: половина
графа живёт в журнале терминала.
- **Вынести общие шаги двух дагов в отдельный модуль.** Третье место для той же
последовательности; расхождение дагов пряталось бы в его параметрах.
- **Применять изменённый DDL при каждом `make up`.** Это мигратор схемы; ему
нужен отдельный разговор, в граф создания мира он не помещается.
- **Выдать права на удаление баз учётной записи `etl`.** Отвергнуто ещё
ADR 0013: конвейер не получает административные права из-за редкой операции.
- **Завести блокировку или статус обслуживания, чтобы пересборка не
сталкивалась с движением мира.** Механизм на редкий ручной случай дороже
предусловия, названного словами.
- **Сторож на `orders_ingest`.** Даг зовут до того, как мир существует; сторож
запретил бы саму инициализацию.
+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).
+50
View File
@@ -0,0 +1,50 @@
# Образ генератора: тот самый «канонический контейнер», внутри которого спека
# обещает побайтовую воспроизводимость (раздел 2). Отсюда правило: пакеты
# ставятся только из `uv.lock` — второй список зависимостей писал бы на стенде
# не те байты, что сторожит `make test`.
#
# Контекст сборки — корень репозитория, а не `generator/`: в образ едет ещё и
# каталог товаров.
# База записана здесь и прибита к patch-версии: это канонический контейнер из
# раздела 2 спеки, и его база входит в тот же фиксированный набор, что `uv.lock`.
# Из `.env` её не берём — иначе описание воспроизводимой сборки распалось бы
# между Dockerfile и обвязкой запуска.
FROM python:3.14.7-slim
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /bin/uv
# Раскладка внутри образа повторяет раскладку репозитория: `/app` — его корень.
# Так надо не для красоты. Каталог товаров генератор ищет от своего модуля
# вверх по дереву (`catalog.py`, `parents[3]`): каталог — часть мира, а не
# запуска, и снаружи не настраивается (решение #39). Значит, взаимное
# расположение пакета и `data/` обязано сохраниться, иначе первый же импорт
# каталога упрётся в отсутствующий файл.
WORKDIR /app/generator
ENV UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
# Зависимости — своим слоем: он пересобирается, только когда меняется лок.
# `--extra kafka` ставит клиента, которым живёт приёмник Kafka; `--no-dev`
# оставляет за бортом pytest, ruff и ty — их дом на машине разработчика.
COPY generator/pyproject.toml generator/uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project --extra kafka
# Пакет в `.venv` не ставится: он лежит исходником и виден через `PYTHONPATH`.
# Поставленный не editable, он переехал бы в `site-packages`, и те же
# `parents[3]` указали бы внутрь `.venv` — каталог в образе есть, а модуль его
# не находит. Заодно правка кода не трогает слой зависимостей.
COPY generator/src ./src
COPY data /app/data
ENV PATH="/app/generator/.venv/bin:$PATH" \
PYTHONPATH="/app/generator/src" \
PYTHONUNBUFFERED=1
# Не root: генератору хватает права читать своё и писать в сеть.
RUN useradd --create-home --uid 1000 generator
USER generator
# Параметры прогона передаёт зовущий — аргументами или окружением; итог он
# читает кодом возврата, а логи идут в стандартный вывод.
ENTRYPOINT ["python", "-m", "clickstream_generator"]
+28
View File
@@ -0,0 +1,28 @@
.PHONY: lint typecheck test demo docs inventory
# --- Проверки ---
lint:
uv run ruff check
uv run ruff format --check
typecheck:
uv run ty check
test:
uv run pytest
# --- Пример из путеводителя ---
demo:
uv run python -m clickstream_generator.demo
# --- Сборка из кода ---
docs:
uv run python -m clickstream_generator.schema_doc \
../docs/formats/clickstream-event.md
inventory:
uv run python -m clickstream_generator.inventory \
../data/world-inventory.json
+46 -33
View File
@@ -4,36 +4,20 @@
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток
трёх видов — просмотр страницы, корзина, покупка. Клиентская сторона на этом
целая; заказы бэкенда и запуск снаружи — за следующими тикетами.
События уже есть и уже уезжают: день-функция отдаёт по паре (зерно, D)
упорядоченный поток трёх видов — просмотр страницы, корзина, покупка, —
канонический сериализатор превращает его в JSON, а проигрыватель гонит в файл
или в Kafka. Клиентская сторона на этом целая. Второй источник строится:
день отдаёт и заказы бэкенда — проекцию своих покупок с деньгами магазина, —
а судьба заказа и отправка слепка идут следующими тикетами.
## Как это работает
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не
> выверен: пишет его тот, кто держит устройство генератора в голове, а это
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48.
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
история для него не нужна и на него не влияет.
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
значит сдвинуть все броски после него, а с ними и весь мир: события того же
дня станут другими, счётчики канонического мира разойдутся с манифестом, и
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
`plan.cohort` бросаются последними.
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
распределения numpy расходятся между версиями и архитектурами, а обещано
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
`rng.choice` с вероятностями.
Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
а день-функция проживает один день без накопленного состояния. Код этой
цепочки, ее схема и запуск D0 собраны в
[путеводителе](../docs/architecture/generator/README.md).
## Что где лежит
@@ -42,8 +26,8 @@ D0 живёт предыстория, поэтому любой день соб
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
подпотока берёт случайность. На ней держится весь детерминизм.
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
день D. Когорты, приток, двухкуковые пары, паспорта кук и счётчики — до
генерации событий.
день D. Когорты, приток, двухкуковые пары, паспорта кук, человек за кукой
и счётчики — до генерации событий.
- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём
на весь генератор, чтобы дисциплина целочисленной случайности не жила
копиями.
@@ -54,30 +38,59 @@ D0 живёт предыстория, поэтому любой день соб
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
который садятся торговые события.
который садятся торговые события; прожитый день отдаёт обе половины —
поток событий и заказы.
- `src/clickstream_generator/commerce.py` — торговые события: что легло в
корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`.
Своя случайность, поэтому правка торговли трафик не двигает.
- `src/clickstream_generator/orders.py` — заказы бэкенда: вторая проекция
покупки. Личность покупателя и деньги магазина — скидка по промокоду,
доставка, итог; своя случайность, ветвящаяся по дню рождения заказа.
- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и
ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно.
- `src/clickstream_generator/serialize.py` — канонический сериализатор:
единственное место, где событие целиком превращается в JSON. Порядок ключей,
все 47 колонок всегда и форма на проводе — ISO-8601.
- `src/clickstream_generator/sinks.py` — приёмники: файл (одно событие — одна
строка) и Kafka (одно событие — одно сообщение). Про содержимое они не
знают; там же довод, почему у сообщения нет ключа.
- `src/clickstream_generator/player.py` — проигрыватель: гонит дни в приёмник
пачкой или с темпом живого дня. Состояния не хранит.
- `src/clickstream_generator/cli.py` — интерфейс запуска: параметры
аргументами или окружением, логи в стандартный вывод, итог кодом возврата.
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
колонках выгрузки. Собственность генератора; из него выводятся сам
генератор, его валидация и описание выгрузки в доках.
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
из контракта. Документ руками не правят — пересобирают.
- `src/clickstream_generator/inventory.py` — сборка описи мира
([`data/world-inventory.json`](../data/world-inventory.json)): паспорт мира
и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов
расхождений по заказам для дней с закрытым окном. Руками не правят —
пересобирают целью `make inventory`.
- `tests/` — инварианты контракта, свежесть описания и обещания мира:
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
и сборка визитов по задокументированным правилам.
и сборка визитов по задокументированным правилам. Там же побайтовое
обещание, доведённое до диска: два прогона дня в файл дают тот же файл, а
строк в нём ровно столько, сколько событий.
## Команды
Из корня репозитория:
Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`.
Из этого каталога — цели генератора живут здесь, а не в корне:
- `make test` — тесты генератора;
- `make lint` — ruff: проверка и формат;
- `make typecheck` — ty: проверка типов;
- `make docs` — пересобрать описание выгрузки.
- `make demo` — пройти стадии канонического D0 на одном экране;
- `make docs` — пересобрать описание выгрузки;
- `make inventory` — пересобрать опись мира.
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
Superset. Одноимённые цели за разными дверями охватывают разное:
[карта проверок](../docs/architecture/testing.md).
Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом
держится обещание побайтовой воспроизводимости (спека, раздел 2).
+9
View File
@@ -11,9 +11,18 @@ dependencies = [
"orjson>=3.11.9",
]
[project.optional-dependencies]
# Клиент Kafka — необязательная часть пакета: без него генератор пишет в файл
# и остаётся переносимым (спека генератора, раздел 9). Приёмник импортирует
# клиента внутри функции, поэтому пакет без этой группы живой.
kafka = ["confluent-kafka>=2.6"]
[dependency-groups]
dev = [
"pytest>=8",
# Тот же клиент нужен и на машине разработчика: иначе ty не разберёт
# импорт приёмника, а ruff и тесты видели бы половину кода.
"confluent-kafka>=2.6",
"ruff>=0.15",
# Типчекер; молод (0.0.x), но версия закреплена в uv.lock. Настройка
# сверена с доками Astral через Context7: запуск `uv run ty check`,
@@ -0,0 +1,9 @@
"""Точка входа пакета: `python -m clickstream_generator`.
Код возврата уходит наружу как есть — по нему судит о прогоне и контейнер, и
даг, который его запустит (спека генератора, раздел 9).
"""
from clickstream_generator.cli import main
raise SystemExit(main())
+222
View File
@@ -0,0 +1,222 @@
"""Интерфейс запуска: `python -m clickstream_generator batch|live|snapshot`.
Зовущий — контейнер (спека генератора, раздел 9), и интерфейс сделан под него:
параметры приходят аргументами или переменными окружения, логи идут в
стандартный вывод, итог виден кодом возврата. Ни файла настроек, ни состояния
между запусками нет — позиция на оси принадлежит тому, кто зовёт.
Зовущих трое, и все трое видны в форме команд:
- даги `world_init` и `next_day` этапа 5 — по дню за запуск, приёмник Kafka;
- заливка стартового мира — восемь дней подряд одним запуском: `--days`;
- проверки хранилища (#43) — ограниченная пачка в файл: `--limit` и `--file`.
**Режимы разведены командами, а не флагом**, потому что различаются не темпом
в числе, а тем, что у них разное: у пакетного есть `--limit` и нет ожидания, у
живого есть `--speed` и нет пачки. Один флаг `--speed 0` прятал бы это
различие за числом, а команда называет его словом. Ограниченная пачка живому
дню не полагается: ждать там нечего — ожидание снимает ускорение.
**Третья команда — `snapshot`** — второй источник стенда: слепок заказов в свой
топик. У неё свой смысл `--day`: это день прогона, а уезжает слепок дня D−1
(правило сдвига живёт в проигрывателе). Прогон дня 0 не отправляет ничего —
на старте оси вчера нет.
**Приёмник выбирается тем, что для него назвали**: `--file` или `--brokers`.
Оба сразу — ошибка, ни одного — тоже: молча выбранный по умолчанию приёмник
однажды напишет в файл то, чего ждали в Kafka.
**Умолчания есть только у того, что описывает мир, а не окружение.** Зерно,
число дней и темп живого дня — свойства модели, они заданы спекой и тикетом,
и умолчание здесь — тот же самый ответ. Позиция на оси (`--day`), адрес
брокера, имя топика и путь файла — факты стенда, на котором нас запустили:
генератор их не знает и знать не должен, он отдельная и переносимая сущность.
Подставь он своё умолчание — забытый параметр превратился бы в неверный ответ,
поданный как успех.
"""
import argparse
import logging
import os
import sys
from collections.abc import Callable, Sequence
from contextlib import closing
from pathlib import Path
from clickstream_generator import player
from clickstream_generator.seeds import CANONICAL_SEED
from clickstream_generator.sinks import FileSink, KafkaSink, Sink
DEFAULT_SPEED = 60.0
def main(argv: Sequence[str] | None = None) -> int:
"""Разобрать аргументы, проиграть дни, вернуть код возврата."""
parser = _parser()
options = parser.parse_args(argv)
logging.basicConfig(
level=logging.INFO, format="%(message)s", stream=sys.stdout, force=True
)
_check(parser, options)
try:
with closing(_sink(parser, options)) as sink:
if options.command == "snapshot":
player.play_snapshots(
sink, seed=options.seed, first_day=options.day, days=options.days
)
else:
player.play(
sink,
seed=options.seed,
first_day=options.day,
days=options.days,
limit=options.limit,
speed=options.speed,
)
except (OSError, RuntimeError) as failure:
logging.error("прогон не удался: %s", failure)
return 1
return 0
def _parser() -> argparse.ArgumentParser:
"""Разбор командной строки; умолчания приходят из окружения."""
parser = argparse.ArgumentParser(
prog="python -m clickstream_generator",
description="Проигрыватель модельных дней кликстрима в файл или Kafka.",
)
commands = parser.add_subparsers(dest="command", required=True)
batch = commands.add_parser(
"batch", help="пачкой, без пауз: заливка снимка и переигровка дня"
)
_common(batch)
batch.set_defaults(speed=None)
batch.add_argument(
"--limit",
type=int,
default=_env_int("GENERATOR_LIMIT", None),
metavar="N",
help="взять не больше N событий на весь прогон, а не день целиком",
)
live = commands.add_parser("live", help="живой день: темп модельного времени")
_common(live)
live.set_defaults(limit=None)
live.add_argument(
"--speed",
type=float,
default=_env_float("GENERATOR_SPEED", DEFAULT_SPEED),
metavar="X",
help=f"ускорение модельного времени (по умолчанию ×{DEFAULT_SPEED:.0f})",
)
snapshot = commands.add_parser(
"snapshot", help="слепок заказов: прогон дня D отправляет слепок дня D−1"
)
_common(snapshot)
return parser
def _common(parser: argparse.ArgumentParser) -> None:
"""Что спрашивают у всех команд: какой мир, какие дни и куда."""
parser.add_argument(
"--seed",
type=int,
default=_env_int("GENERATOR_SEED", CANONICAL_SEED),
metavar="N",
help="зерно мира (по умолчанию каноническое)",
)
parser.add_argument(
"--day",
type=int,
default=_env_int("GENERATOR_DAY", None),
metavar="D",
help="номер дня на оси мира (D0 — первый); у snapshot это день прогона,"
" а уезжает слепок дня D−1; умолчания нет — позицию ведёт зовущий",
)
parser.add_argument(
"--days",
type=int,
default=_env_int("GENERATOR_DAYS", 1),
metavar="N",
help="сколько дней подряд проиграть одним запуском",
)
parser.add_argument(
"--file",
type=Path,
default=_env("GENERATOR_FILE", None, Path),
metavar="ПУТЬ",
help="приёмник — файл: одна запись в строке",
)
parser.add_argument(
"--brokers",
default=os.environ.get("KAFKA_BOOTSTRAP_SERVERS"),
metavar="АДРЕС",
help="приёмник — Kafka: адреса брокеров через запятую",
)
parser.add_argument(
"--topic",
default=os.environ.get("KAFKA_TOPIC"),
metavar="ИМЯ",
help="топик Kafka; умолчания нет — имя топика принадлежит стенду, а не пакету",
)
def _check(parser: argparse.ArgumentParser, options: argparse.Namespace) -> None:
"""День на оси обязан быть назван — иначе прогон не начинается."""
if options.day is None:
parser.error(
"день на оси не назван: передайте --day или GENERATOR_DAY."
" Позицию ведёт зовущий — проигрыватель её не помнит"
)
def _sink(parser: argparse.ArgumentParser, options: argparse.Namespace) -> Sink:
"""Приёмник по названному: файл либо Kafka, но не оба и не ничего."""
if options.file and options.brokers:
parser.error("названы оба приёмника: оставьте --file либо --brokers")
if options.file:
return FileSink(options.file)
if options.brokers:
if not options.topic:
parser.error(
"топик не назван: передайте --topic или KAFKA_TOPIC."
" Имя топика — факт стенда, генератор его не знает"
)
try:
return KafkaSink(options.brokers, options.topic)
except ImportError:
# Ловим там, где возникает: обёрнутый вокруг всего прогона,
# этот перехват однажды объявил бы «нет клиента Kafka» о чужой
# сорванной загрузке модуля.
parser.error(
"приёмник Kafka требует клиента: поставьте пакет с группой"
" зависимостей kafka (`uv sync --extra kafka`)"
)
parser.error("приёмник не назван: нужен --file либо --brokers")
def _env[T](name: str, fallback: T, kind: Callable[[str], T]) -> T:
"""Значение переменной окружения нужного типа; нет переменной — умолчание.
Пустая строка считается отсутствием: в compose так выглядит переменная,
которую не задали, — `${GENERATOR_DAYS:-}`. Мусор в переменной называется
вместе с её именем: из контейнера иначе не видно, чьё это значение.
"""
value = os.environ.get(name)
if not value:
return fallback
try:
return kind(value)
except ValueError:
raise SystemExit(f"переменная {name} не разбирается: {value!r}") from None
def _env_int(name: str, fallback: int | None) -> int | None:
return _env(name, fallback, int)
def _env_float(name: str, fallback: float) -> float:
return _env(name, fallback, float)
+310 -19
View File
@@ -39,12 +39,21 @@
дошедшие до потока дня, в порядке событий — до всяких потерь; поэтому номер
присваивается последним ходом, когда поток уже упорядочен.
**Второй выход — покупки дня.** Кроме потока событий торговая половина
отдаёт покупки готовой структурой: номер заказа, корзина, деньги клиента,
промокод и человек за кукой. Заказная сторона берёт их такими и ничего не
пересчитывает: заказ — вторая проекция той же покупки, поэтому согласие двух
источников не удерживается, а выходит по построению
(docs/architecture/orders/snapshot.md).
**Случайность — подпоток `COMMERCE`** (спека генератора, раздел 2): правка
торгового поведения не сдвигает трафиковый поток. Броски целые и векторные;
посточно собираются только строки — их numpy не умеет.
посточно собираются только строки — их numpy не умеет. Новых бросков заказы
сюда не добавляют: свои решения заказная сторона тянет из своего подпотока.
"""
from dataclasses import dataclass
from enum import IntEnum
from typing import Any
import numpy as np
@@ -123,19 +132,85 @@ class _Draws:
order_delay: NDArray[np.int64]
@dataclass(frozen=True, slots=True)
class Purchases:
"""Покупки дня готовой структурой — второй выход торговой половины.
Ряды одной длины, по элементу на покупку, в порядке номеров заказа.
Корзина лежит парой рядов ячеек — номера товаров каталога и штуки; в
каждой ячейке свой массив, длиной в число позиций покупки.
"""
order_id: tuple[str, ...]
# Человек за кукой, которая купила: у заказа он назовётся `user_id`.
person_id: NDArray[np.uint64]
product: tuple[NDArray[np.int64], ...]
quantity: tuple[NDArray[np.int64], ...]
# Промокод события строкой; у покупки без кода — пустая.
coupon: tuple[str, ...]
# Деньги клиента целыми копейками: сумма позиций без скидки и доставки,
# та самая, что уехала в событие как `purchaseRevenue`. Бэкенд назовёт
# её `items_total` — у двух источников свои имена одному числу.
revenue: NDArray[np.int64]
# Когда покупка подтверждена — та же абсолютная метка, что уехала в
# событие. У заказа она станет секундой `created_at`: строка в базе
# источника создаётся синхронно с покупкой.
moment: NDArray[np.datetime64]
def __len__(self) -> int:
return self.person_id.size
class EventOutcome(IntEnum):
"""Что случилось с маячком purchase после создания заказа."""
DELIVERED = 0
LOST = 1
DUPLICATED = 2
@dataclass(frozen=True, slots=True)
class _Events:
"""Строки одного вида торговых событий, готовые встать в поток.
`raw` — сырой `ecommerce` каждой строки ещё объектом: номер заказа в нём
появится, когда поток будет упорядочен, а строка станет байтами один
раз, каноническим сериализатором.
раз, каноническим сериализатором. `basket` говорит, чья корзина стоит за
строкой: по нему покупка находит свою после того, как поток упорядочен.
"""
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
raw: list[dict[str, Any]]
basket: NDArray[np.int64]
@dataclass(frozen=True, slots=True)
class Woven:
"""Что торговая половина отдает дню после вплетения событий."""
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
purchases: Purchases
event_outcome: NDArray[np.int8]
# Для каждой покупки: назначен ли её заказ планом.
assigned_order: NDArray[np.bool_]
# День без единой корзины: покупок в нём нет, а форма у рядов есть.
_NO_PURCHASES = Purchases(
order_id=(),
person_id=np.empty(0, dtype=np.uint64),
product=(),
quantity=(),
coupon=(),
revenue=np.empty(0, dtype=np.int64),
moment=np.empty(0, dtype="datetime64[s]"),
)
_NO_EVENT_OUTCOMES = np.empty(0, dtype=np.int8)
_NO_ASSIGNED_ORDERS = np.empty(0, dtype=np.bool_)
def weave(
@@ -144,24 +219,54 @@ def weave(
columns: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
"""Вплетает торговые события в трафиковый поток и отдаёт поток целиком.
person: NDArray[np.uint64],
assigned_order: NDArray[np.bool_],
) -> Woven:
"""Вплетает торговые события в поток и отдаёт поток и покупки дня.
Строки приходят упорядоченными по времени и такими же уходят: торговые
события встают между ними, и поток пересобирается одним порядком.
события встают между ними, и поток пересобирается одним порядком. Второй
выход — покупки дня: то же самое, чем они уехали в события.
`person` и `assigned_order` выровнены по входящему потоку. Оба
признака приходят из плана состава: личность дает заказу `user_id`,
а назначение защищает мост склейки от потери purchase.
"""
rng = day_stream(seed, day, Component.COMMERCE)
baskets = _baskets(rng, page, product, columns["VisitID"])
if not len(baskets):
return columns, page, product
return Woven(
columns=columns,
page=page,
product=product,
purchases=_NO_PURCHASES,
event_outcome=_NO_EVENT_OUTCOMES,
assigned_order=_NO_ASSIGNED_ORDERS,
)
goods = catalog.catalog()
draws = _draws(rng, baskets)
revenue = _revenue(goods, baskets, draws)
coupons = _coupon_codes(draws)
events = (
_cart_events(columns, baskets, goods, draws),
_order_events(columns, baskets, goods, draws),
_order_events(columns, baskets, goods, draws, revenue, coupons),
)
columns, page, product, basket = _stream(rng, columns, page, product, events)
purchases, assigned = _purchases(
columns, basket, baskets, draws, revenue, coupons, person, assigned_order
)
columns, page, product, event_outcome = _corrupt_events(
seed, day, columns, page, product, purchases, assigned
)
return Woven(
columns=columns,
page=page,
product=product,
purchases=purchases,
event_outcome=event_outcome,
assigned_order=assigned,
)
return _stream(rng, columns, page, product, events)
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
@@ -307,6 +412,36 @@ def _coupons(rng: np.random.Generator, baskets: int) -> NDArray[np.int64]:
return np.where(rng.integers(0, 100, baskets) < world.COUPON_PERCENT, code, -1)
def _coupon_codes(draws: _Draws) -> NDArray[np.object_]:
"""Промокод каждой корзины строкой; у корзины без кода — пустая.
Код нужен обеим сторонам: событие везёт его как есть, а бэкенд по нему
считает скидку заказа — по той же таблице чисел мира.
"""
return np.array(
[
world.COUPONS[number][0] if number >= 0 else ""
for number in draws.coupon.tolist()
],
dtype=object,
)
def _revenue(
goods: catalog.Catalog, baskets: _Baskets, draws: _Draws
) -> NDArray[np.int64]:
"""Деньги клиента по корзинам, целыми копейками: сумма купленных позиций.
Сумма считается один раз на день и уезжает в оба источника: в событие —
как `purchaseRevenue`, в заказ — как `items_total`. Разойтись им негде,
и это не совпадение, а построение.
"""
value = goods.price[baskets.product] * draws.quantity
kopecks = np.zeros(len(baskets), dtype=np.int64)
np.add.at(kopecks, baskets.basket[draws.kept], value[draws.kept])
return kopecks
def _cart_events(
columns: dict[str, NDArray[Any]],
baskets: _Baskets,
@@ -329,6 +464,7 @@ def _cart_events(
page=np.full(positions, Page.PRODUCT, dtype=np.uint8),
product=baskets.product.copy(),
raw=[{"currencyCode": world.CURRENCY, ADD_ACTION: block} for block in blocks],
basket=baskets.basket,
)
@@ -337,6 +473,8 @@ def _order_events(
baskets: _Baskets,
goods: catalog.Catalog,
draws: _Draws,
revenue: NDArray[np.int64],
coupons: NDArray[np.object_],
) -> _Events:
"""Строки `purchase`: по одной на корзину, дошедшую до подтверждения."""
ordered = np.flatnonzero(baskets.confirmation >= 0)
@@ -352,14 +490,9 @@ def _order_events(
rows.update(side)
# Выручка клиента — сумма позиций без скидки и доставки, целыми копейками.
kopecks = [
int((goods.price[baskets.product[group]] * draws.quantity[group]).sum())
for group in bought
]
codes = [
world.COUPONS[number][0] if number >= 0 else ""
for number in draws.coupon[ordered].tolist()
]
# Считана она один раз на день: тем же числом её возьмёт заказ бэкенда.
kopecks = revenue[ordered].tolist()
codes = coupons[ordered].tolist()
rows["purchaseRevenue"] = _cells(
[np.array([money / KOPECKS], dtype=np.float64) for money in kopecks]
)
@@ -380,6 +513,7 @@ def _order_events(
page=np.full(ordered.size, Page.CONFIRMATION, dtype=np.uint8),
product=np.full(ordered.size, -1, dtype=np.int64),
raw=raw,
basket=ordered,
)
@@ -479,8 +613,15 @@ def _stream(
page: NDArray[np.uint8],
product: NDArray[np.int64],
events: tuple[_Events, ...],
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
"""Собирает поток дня целиком: сутки режут хвост, время задаёт порядок."""
) -> tuple[
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int64]
]:
"""Собирает поток дня целиком: сутки режут хвост, время задаёт порядок.
Четвёртым рядом уходит корзина каждой строки — у просмотра страницы её
нет: по ней покупка находит свою корзину, когда поток уже упорядочен и
полночь свой хвост отрезала.
"""
traffic = page.size
trade = {
name: np.concatenate([part.columns[name] for part in events])
@@ -506,10 +647,17 @@ def _stream(
place = np.full(traffic + len(raw), -1, dtype=np.int64)
place[traffic:] = np.arange(len(raw))
basket = np.concatenate(
(
np.full(traffic, -1, dtype=np.int64),
np.concatenate([part.basket for part in events])[alive],
)
)
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
rows = {name: value[order] for name, value in rows.items()}
_seal(rows, raw, place[order], _order_prefix(columns))
return rows, page[order], product[order]
return rows, page[order], product[order], basket[order]
def _seal(
@@ -537,6 +685,149 @@ def _seal(
rows["ecommerce"][row] = orjson.dumps(block).decode()
def _purchases(
rows: dict[str, NDArray[Any]],
basket: NDArray[np.int64],
baskets: _Baskets,
draws: _Draws,
revenue: NDArray[np.int64],
coupons: NDArray[np.object_],
person: NDArray[np.uint64],
assigned_order: NDArray[np.bool_],
) -> tuple[Purchases, NDArray[np.bool_]]:
"""Покупки дня — то же, чем они уехали в события, только структурой.
Порядок здесь — порядок строк потока, он же порядок номеров заказа:
поток уже упорядочен и пронумерован, полночь свой хвост уже отрезала.
Человек берётся у строки подтверждения — визит принадлежит одной куке,
а кука одному человеку.
"""
here = np.flatnonzero(rows["EventType"] == PURCHASE)
mine = basket[here]
bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()]
purchases = Purchases(
order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()),
person_id=person[baskets.confirmation[mine]],
product=tuple(baskets.product[group] for group in bought),
quantity=tuple(draws.quantity[group] for group in bought),
coupon=tuple(coupons[mine].tolist()),
revenue=revenue[mine],
moment=rows["UTCEventTime"][here],
)
return purchases, assigned_order[baskets.confirmation[mine]]
def _corrupt_events(
seed: int,
day: int,
rows: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
purchases: Purchases,
assigned: NDArray[np.bool_],
) -> tuple[
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int8]
]:
"""Теряет purchase или повторяет пару подтверждения.
Заказы уже собраны, а номера покупок уже вписаны в строки. Поэтому
порча меняет только событийную сторону. Все броски делаются на полную
длину покупок дня, чтобы отбор одного класса не сдвигал другой.
"""
count = len(purchases)
if not count:
return rows, page, product, _NO_EVENT_OUTCOMES
rng = day_stream(seed, day, Component.BEACONS)
lost = (rng.integers(0, 100, count) < world.LOST_PURCHASE_PERCENT) & ~assigned
duplicate_draw = rng.integers(0, 100, count) < world.DUPLICATE_PURCHASE_PERCENT
delay = rng.integers(*world.DUPLICATE_DELAY_SECONDS, count)
watch = ids.unique_apart_from(rng, 2 * count, rows["WatchID"]).reshape(count, 2)
purchase_rows = np.flatnonzero(rows["EventType"] == PURCHASE)
confirmation_rows = _confirmation_rows(rows, page, purchase_rows)
eligible = _duplicate_eligibility(rows, purchase_rows, delay)
duplicated = ~lost & duplicate_draw & eligible
outcome = np.full(count, EventOutcome.DELIVERED, dtype=np.int8)
outcome[lost] = EventOutcome.LOST
outcome[duplicated] = EventOutcome.DUPLICATED
keep = np.ones(page.size, dtype=bool)
keep[purchase_rows[lost]] = False
sources = np.column_stack(
(confirmation_rows[duplicated], purchase_rows[duplicated])
)
selected = np.flatnonzero(duplicated)
if selected.size:
copies = sources.ravel()
shifted = np.repeat(delay[selected], 2).astype("timedelta64[s]")
additions = {name: values[copies] for name, values in rows.items()}
additions["UTCEventTime"] = additions["UTCEventTime"] + shifted
additions["WatchID"] = watch[selected].ravel()
# Первая строка пары — обновление confirmation: ее предыдущая
# страница — она же, а не checkout из первого просмотра.
additions["Referer"] = additions["URL"].copy()
rows = {
name: np.concatenate((values[keep], additions[name]))
for name, values in rows.items()
}
page = np.concatenate((page[keep], page[copies]))
product = np.concatenate((product[keep], product[copies]))
else:
rows = {name: values[keep] for name, values in rows.items()}
page = page[keep]
product = product[keep]
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
return (
{name: values[order] for name, values in rows.items()},
page[order],
product[order],
outcome,
)
def _confirmation_rows(
rows: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
purchase_rows: NDArray[np.int64],
) -> NDArray[np.int64]:
"""Находит просмотр, с которым purchase образует пару."""
confirmation = (rows["EventType"] == "pageview") & (page == Page.CONFIRMATION)
by_visit = {
int(rows["VisitID"][row]): row for row in np.flatnonzero(confirmation).tolist()
}
return np.array(
[by_visit[int(rows["VisitID"][row])] for row in purchase_rows.tolist()],
dtype=np.int64,
)
def _duplicate_eligibility(
rows: dict[str, NDArray[Any]],
purchase_rows: NDArray[np.int64],
delay: NDArray[np.int64],
) -> NDArray[np.bool_]:
"""Проверяет полночь и запас до следующего визита куки."""
moment = rows["UTCEventTime"][purchase_rows]
shifted = moment + delay.astype("timedelta64[s]")
before_midnight = shifted < _midnight(rows) + np.timedelta64(1, "D")
eligible = before_midnight.copy()
for number, row in enumerate(purchase_rows.tolist()):
later_visit = (
(rows["ClientID"] == rows["ClientID"][row])
& (rows["VisitID"] != rows["VisitID"][row])
& (rows["UTCEventTime"] > moment[number])
)
if np.any(later_visit):
next_start = rows["UTCEventTime"][later_visit].min()
eligible[number] &= next_start - moment[number] > np.timedelta64(
world.VISIT_TIMEOUT_SECONDS + int(delay[number]), "s"
)
return eligible
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
"""Начало модельных суток абсолютной меткой: полночь в поясе счётчика."""
date: np.datetime64 = columns["EventDate"][0]
+39 -15
View File
@@ -36,15 +36,21 @@
гарантия, ради неё мы сужаем свободу старта. Цена названа — около 24
визитов в день из ~9,5 тыс. не начинаются в последние минуты суток.
**Шов с торговыми событиями.** Поток несёт, кроме колонок, два выровненных
по строкам ряда: `page` — какая это страница магазина, и `product` — какой
товар показывала карточка (−1 у прочих страниц). По ним `commerce` знает и
то, куда сажать событие (карточка, подтверждение), и то, что посетитель на
самом деле смотрел: товар в корзине, которого никто не открывал, — видимая
глупость в воронке. Визит с назначенным заказом всегда доходит до
`/confirmation`, а перед корзиной у него всегда есть карточка товара.
Случайность у половин разная: трафик берёт подпоток `TRAFFIC`, торговля —
`COMMERCE`, и правка одной не сдвигает другую.
**Шов с торговыми событиями.** Поток несёт, кроме колонок, три выровненных
по строкам ряда: `page` — какая это страница магазина, `product` — какой
товар показывала карточка (−1 у прочих страниц), и `person` — человек за
кукой этой строки. По первым двум `commerce` знает и то, куда сажать событие
(карточка, подтверждение), и то, что посетитель на самом деле смотрел: товар
в корзине, которого никто не открывал, — видимая глупость в воронке. Третий
в события не попадает вовсе: личность нужна заказу, а кликстрим анонимен.
Визит с назначенным заказом всегда доходит до `/confirmation`, а перед
корзиной у него всегда есть карточка товара. Случайность у половин разная:
трафик берёт подпоток `TRAFFIC`, торговля — `COMMERCE`, заказы — свой, и
правка одной не сдвигает другие.
**Заказы бэкенда** день отдаёт второй половиной: покупки, которые собрала
торговая половина, заказная превращает в заказы дня — с личностью
покупателя и деньгами магазина (`orders`).
"""
from dataclasses import dataclass
@@ -53,7 +59,7 @@ from typing import Any
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, ids, plan, reference, world
from clickstream_generator import catalog, commerce, ids, orders, plan, reference, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import Component, day_stream
from clickstream_generator.weights import pick, pick_row
@@ -78,18 +84,24 @@ _SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SO
@dataclass(frozen=True, slots=True)
class Day:
"""Поток событий одного дня: колонки выгрузки и страницы за ними.
"""Прожитый день: поток событий трекера и заказы бэкенда.
Строки упорядочены по времени — так их и проиграет проигрыватель.
`columns` — колонки контракта схемы по его порядку, все до одной;
`page` и `product` выровнены по тем же строкам (см. шов в докстринге
модуля).
модуля). `orders` — вторая половина дня: заказы, рождённые в нём.
Половины уезжают разными командами и в разные топики, но считаются
вместе: заказ — проекция покупки, и порознь им разойтись негде.
"""
day: int
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
orders: orders.Orders
# Событийный исход каждого заказа, в том же порядке.
purchase_outcome: NDArray[np.int8]
def __len__(self) -> int:
return self.page.size
@@ -149,15 +161,27 @@ def stream(seed: int, day: int) -> Day:
order = np.lexsort((columns["WatchID"], columns["UTCEventTime"]))
# Торговые события садятся на готовый трафиковый поток и отдают его
# целиком: в нём же они и упорядочиваются.
columns, page, product = commerce.weave(
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят
# покупки дня — из них заказная сторона собирает заказы.
person = np.repeat(audience.person_id[visits.cookie], visits.pages)
assigned_order = np.repeat(visits.ordering, visits.pages)
woven = commerce.weave(
seed,
day,
{name: value[order] for name, value in columns.items()},
page[alive][order],
product[alive][order],
person[alive][order],
assigned_order[alive][order],
)
return Day(
day=day,
columns=woven.columns,
page=woven.page,
product=woven.product,
orders=orders.of_day(seed, day, woven.purchases, woven.assigned_order),
purchase_outcome=woven.event_outcome,
)
return Day(day=day, columns=columns, page=page, product=product)
def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
@@ -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()
@@ -0,0 +1,181 @@
"""Опись мира: чем стенд наполняется при подъёме и каким это обязано выйти.
Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не
он сам, а опись: паспорт мира, число событий и хеш байтов каждого дня, счётчики
классов расхождений по заказам. Сам мир пересчитывается когда угодно, а опись
отвечает на единственный вопрос — **тот ли это мир, что был вчера**. Разошлись
хеши — мир уехал, и дальше уже неважно, чего от него ждали проверки.
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот
же файл, а не заведет второй.
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
сдвинется, — версия останется прежней, а хеши покраснеют; наоборот не бывает.
Хеш каталога стоит здесь по третьему основанию — ни сторожить, ни описывать, а
**объяснять**. Правка цены в `data/catalog/products.csv` меняет мир так же
молча, как правка кода, и по одним хешам эти два случая неразличимы. С хешем
каталога различимы: разошлись хеши дней и каталога — правили CSV; разошлись
только дни — правили код.
Хеш дня — sha256 тех самых байтов, что уезжают в Kafka, с переводом строки
после каждого события. Это ровно то, что пишет файловый приёмник, поэтому
пересчитывается он и обычным `sha256sum` по сыгранному в файл дню (как
именно — в README репозитория).
Слепок заказов — второй артефакт мира, и хешами дней он не покрыт ни при каком
раскладе подпотоков, поэтому у каждого отправленного слепка своя строка. Их на
один меньше, чем дней: прогон дня 0 отправлять ещё нечего. Счёта строк у
слепка нет — опись описывает мир, а не доставку.
Собирается опись из каталога генератора целью `make inventory`, а свежесть её
сторожит тест — как и у «описания выгрузки».
"""
import argparse
import hashlib
import json
from datetime import timedelta
from importlib.metadata import version
from pathlib import Path
from typing import Any
from clickstream_generator import commerce, serialize, world
from clickstream_generator import day as day_module
from clickstream_generator import orders as orders_module
from clickstream_generator.catalog import CATALOG_PATH
from clickstream_generator.seeds import CANONICAL_SEED
# Сколько дней оси описывает стартовый мир. То же число Airflow получает из
# compose.yaml: YAML не читает Python, и одно из двух мест лишнее по построению.
# Расхождение поймают счётчики make check-clickhouse.
STARTING_DAYS = 8
def build() -> dict[str, Any]:
"""Опись целиком: паспорт мира, строка на день и строка на слепок.
Дни играются по одному и отпускаются: заказы дня остаются, потому что из
них собираются слепки, а полсотни тысяч событий восьми дней сразу в память
не нужны.
"""
days = []
orders = []
for number in range(STARTING_DAYS):
today = day_module.stream(CANONICAL_SEED, number)
row = _day(number, today)
if number < STARTING_DAYS - world.ORDER_WINDOW_DAYS:
row["orders"] = _order_class_counts(today)
days.append(row)
orders.append(today.orders)
return {
"seed": CANONICAL_SEED,
"generator_version": version("clickstream-generator"),
"catalog_sha256": _digest(CATALOG_PATH.read_bytes()),
"days": days,
"snapshots": [_snapshot(number, orders) for number in range(STARTING_DAYS - 1)],
}
def render() -> str:
"""Опись текстом файла: отступы в два пробела, кириллица как есть."""
return json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
def _day(number: int, today: day_module.Day) -> dict[str, Any]:
"""Строка описи: номер дня, его дата, число событий и хеш байтов.
Дата считается от D0 арифметикой, а не берётся из событий: ось модельного
времени так и определена (`world.ORIGIN`), и по этой же дате счётчики
стенда обрамляют счёт в `ods.event`. Соври она — подневная сверка это и
покажет, каждый день сразу.
"""
payloads = serialize.events(today)
return {
"day": number,
"date": _date(number),
"events": len(payloads),
"sha256": _digest(b"".join(payload + b"\n" for payload in payloads)),
}
def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]:
"""Строка описи слепка: какой день снят, его дата и хеш отправленных байтов.
Байты те же, что уезжают в топик `orders`, с переводом строки после
каждого заказа: пересъёмка слепка обязана дать их снова.
"""
window = [orders[born] for born in orders_module.window(number)]
payloads = serialize.orders(window, number)
return {
"day": number,
"date": _date(number),
"sha256": _digest(b"".join(payload + b"\n" for payload in payloads)),
}
def _order_class_counts(today: day_module.Day) -> dict[str, int]:
"""Заказы дня по итоговому классу по приоритету спеки."""
purchase = today.columns["EventType"] == commerce.PURCHASE
declared = {
purchase_id[0]: round(revenue[0] * commerce.KOPECKS)
for purchase_id, revenue in zip(
today.columns["purchaseID"][purchase],
today.columns["purchaseRevenue"][purchase],
strict=True,
)
}
counts = {
"match": 0,
"cancelled": 0,
"lost_event": 0,
"duplicate_event": 0,
"amount_delta": 0,
}
for order_id, order_outcome, event_outcome, items_total in zip(
today.orders.order_id,
today.orders.outcome,
today.purchase_outcome,
today.orders.items_total,
strict=True,
):
if order_outcome != orders_module.OrderOutcome.PAID:
counts["cancelled"] += 1
elif event_outcome == commerce.EventOutcome.LOST:
counts["lost_event"] += 1
elif event_outcome == commerce.EventOutcome.DUPLICATED:
counts["duplicate_event"] += 1
elif items_total != declared[order_id]:
counts["amount_delta"] += 1
else:
counts["match"] += 1
return counts
def _date(number: int) -> str:
return (world.ORIGIN + timedelta(days=number)).isoformat()
def _digest(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def main() -> None:
parser = argparse.ArgumentParser(
description="Собирает опись мира: паспорт, счётчики и хеши дней."
)
parser.add_argument("output", type=Path, help="путь к файлу описи")
output = parser.parse_args().output
output.write_text(render(), encoding="utf-8")
print(f"Опись мира собрана: {output}")
if __name__ == "__main__":
main()
@@ -0,0 +1,305 @@
"""Заказы бэкенда: вторая проекция покупки и деньги магазина.
Заказ — не второе порождение, а вторая проекция того же факта мира. Корзину,
цены, промокод и номер посчитала торговая половина дня-функции; заказная
половина берёт её покупки готовой структурой и добавляет то, чего у клиента
нет: личность покупателя под родным именем `user_id` и деньги магазина —
скидку по коду, доставку и итог. Новых бросков в торговый подпоток заказная
половина не делает, поэтому два источника согласованы по построению, а не
сверкой (docs/architecture/orders/snapshot.md).
**Деньги — целыми копейками**, как и везде в генераторе: `items_total` —
сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся
выручкой), за вычетом строки, которую унесла дельта; `discount` — скидка по
промокоду события от оставшегося `items_total`, по таблице «код → скидка» из
чисел мира; `delivery` — единственные деньги заказа, которых нет ни в одном
событии; `total` — `items_total` − `discount` + `delivery`. Отсюда правило
витрин «деньги считаем по бэкенду»: про скидку и доставку клиент не знает
вовсе.
**Случайность — подпоток заказной стороны**, ветвящийся по дню рождения
заказа: слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан
читать из его собственного дня. Броски делаются на полную длину дня, а не на
отобранных заказах, — иначе длина броска стала бы функцией доли, и правка
одной доли перебрасывала бы весь подпоток после себя
(docs/architecture/orders/fate.md).
**Судьба заказа — исход, его моменты и задержка выгрузки**. Из окна
изменяемости заказ выходит оплаченным или отменённым, а когда именно это
случилось, сказано смещением в секундах от рождения заказа. Момента, которого
у исхода нет, нет и в данных: его место занимает −1, а не ноль, — иначе
«оплатили в секунду рождения» было бы не отличить от «не оплатили вовсе».
Задержка говорит, сколько ранних слепков заказ пропустит; назначенный планом
приезжает сразу.
**Состояние на границе суток** — чтение готовой судьбы, а не накопление:
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
статус. Отсюда «дыхание» окна — заказ, оплаченный назавтра, стоит в сегодняшнем
слепке как `created`, а завтра как `paid`.
**Дельта суммы — вычеркнутая позиция**: товара не оказалось в наличии, и
заказ приезжает на строку короче клиентской корзины, а `items_total` меньше
ровно на её полную стоимость. Момента у дельты нет — склад собрал заказ до
первого слепка, поэтому урезан он во всех своих слепках. Заказ из одной
позиции дельты не получает: пустых заказов не бывает.
"""
from dataclasses import dataclass
from enum import IntEnum
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import catalog, world
from clickstream_generator.commerce import Purchases
from clickstream_generator.seeds import Component, day_stream
from clickstream_generator.weights import pick
_DISCOUNT_PERCENT = dict(world.COUPONS)
_DELIVERY_PRICE = np.array(
[price for price, _ in world.DELIVERY_KOPECKS_WEIGHTS], dtype=np.int64
)
_DELIVERY_CUMULATIVE = np.cumsum(
[weight for _, weight in world.DELIVERY_KOPECKS_WEIGHTS]
)
_OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS)
_MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS)
_SNAPSHOT_DELAY_CUMULATIVE = np.cumsum(world.ORDER_SNAPSHOT_DELAY_WEIGHTS)
class OrderOutcome(IntEnum):
"""Чем кончилось окно изменяемости заказа. Порядок — порядок весов мира."""
PAID = 0
PAID_THEN_CANCELLED = 1
UNPAID_THEN_CANCELLED = 2
@dataclass(frozen=True, slots=True)
class Orders:
"""Заказы одного модельного дня: ряды одной длины, по элементу на заказ.
Порядок — порядок рождения заказов, он же возрастание `order_id`. День
рождения заказа — `day`: строка в базе источника создаётся синхронно с
покупкой, поэтому день создания строки и день покупки совпадают.
"""
day: int
order_id: tuple[str, ...]
# Пользователь магазина: тот же человек, что стоит за купившей кукой.
user_id: NDArray[np.uint64]
# Когда строка заказа создана в базе источника: секунда той самой покупки
# — в модели строка создаётся синхронно с ней — и миллисекунда часов базы.
# От этого момента отсчитываются и моменты судьбы.
created_at: NDArray[np.datetime64]
# Позиции заказа: номера товаров каталога и штуки, ячейка на заказ. У
# заказа с дельтой позиций на одну меньше, чем в корзине клиента.
product: tuple[NDArray[np.int64], ...]
quantity: tuple[NDArray[np.int64], ...]
# Деньги заказа, целые копейки.
items_total: NDArray[np.int64]
discount: NDArray[np.int64]
delivery: NDArray[np.int64]
total: NDArray[np.int64]
# Судьба заказа: исход (`OrderOutcome`) и его моменты — смещения в
# секундах от рождения заказа, −1 у момента, которого у исхода нет.
outcome: NDArray[np.int64]
paid_after: NDArray[np.int64]
cancelled_after: NDArray[np.int64]
snapshot_delay: NDArray[np.int64]
def __len__(self) -> int:
return self.user_id.size
def of_day(
seed: int,
day: int,
purchases: Purchases,
assigned_order: NDArray[np.bool_],
) -> Orders:
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
rng = day_stream(seed, day, Component.ORDERS)
delivery = _delivery(rng, len(purchases))
outcome, paid_after, cancelled_after = _fate(rng, len(purchases))
product, quantity, items_total = _delta(rng, purchases)
created_at = _created_at(rng, purchases)
discount = _discount(purchases, items_total)
snapshot_delay = _snapshot_delay(rng, assigned_order)
return Orders(
day=day,
order_id=purchases.order_id,
user_id=purchases.person_id,
created_at=created_at,
product=product,
quantity=quantity,
items_total=items_total,
discount=discount,
delivery=delivery,
total=items_total - discount + delivery,
outcome=outcome,
paid_after=paid_after,
cancelled_after=cancelled_after,
snapshot_delay=snapshot_delay,
)
def window(day: int) -> range:
"""Дни рождения, чьи заказы несёт слепок дня `day`.
Окно изменяемости — константа мира; у начала оси оно усекается само, а не
сторожем: дней до D0 попросту нет.
"""
return range(max(0, day - world.ORDER_WINDOW_DAYS + 1), day + 1)
def at_boundary(rows: Orders, day: int) -> tuple[list[str], NDArray[np.datetime64]]:
"""Статус заказов и момент их последнего изменения на границе `day`|`day+1`.
Судьба решена при рождении, поэтому слепок её только читает: момент позже
границы для него ещё не случился. Отмена перевешивает оплату — у дороги
«оплачен и отменён» она поздняя, и заказ на границе уже отменён.
`updated_at` — поздний учтённый момент, а без единого заказ показывает своё
рождение: строку с тех пор никто не трогал.
"""
edge = _boundary(day)
paid = rows.created_at + rows.paid_after.astype("timedelta64[s]")
cancelled = rows.created_at + rows.cancelled_after.astype("timedelta64[s]")
# Момента, которого у исхода нет, в данных нет вовсе: там −1, и без маски
# он прикинулся бы моментом за секунду до рождения.
got_paid = (rows.paid_after >= 0) & (paid <= edge)
got_cancelled = (rows.cancelled_after >= 0) & (cancelled <= edge)
status = np.where(got_cancelled, "cancelled", np.where(got_paid, "paid", "created"))
updated = np.where(
got_cancelled, cancelled, np.where(got_paid, paid, rows.created_at)
)
return status.tolist(), updated
def arrived(rows: Orders, day: int) -> NDArray[np.bool_]:
"""Какие заказы уже попали в выгрузку к слепку дня `day`."""
return rows.day + rows.snapshot_delay <= day
def _boundary(day: int) -> np.datetime64:
"""Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика.
Модельные сутки считаются в поясе счётчика, а моменты заказа — метки UTC:
между ними ровно смещение пояса.
"""
midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(day + 1, "D")
return midnight - np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m")
def _delivery(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
"""Стоимость доставки каждого заказа дня — броском по таблице весов."""
return _DELIVERY_PRICE[pick(rng, _DELIVERY_CUMULATIVE, orders)]
def _fate(
rng: np.random.Generator, orders: int
) -> tuple[NDArray[np.int64], NDArray[np.int64], NDArray[np.int64]]:
"""Исход каждого заказа и моменты, которые этот исход себе оставил.
Моментов бросается два, и всегда два — обоим исходам с одним моментом
второй достаётся лишним и выбрасывается. У двух дорог заказа порядок
выходит сортировкой: ранний момент — оплата, поздний — отмена, — а не
условной точкой отсчёта, от которой отмеряется вторая.
"""
outcome = pick(rng, _OUTCOME_CUMULATIVE, orders)
first, second = _moment(rng, orders), _moment(rng, orders)
both = outcome == OrderOutcome.PAID_THEN_CANCELLED
paid = np.where(outcome == OrderOutcome.UNPAID_THEN_CANCELLED, -1, first)
paid = np.where(both, np.minimum(first, second), paid)
cancelled = np.where(outcome == OrderOutcome.PAID, -1, first)
cancelled = np.where(both, np.maximum(first, second), cancelled)
return outcome, paid, cancelled
def _created_at(
rng: np.random.Generator, purchases: Purchases
) -> NDArray[np.datetime64]:
"""Момент создания строки заказа: секунда покупки и миллисекунда часов базы.
Секунда приходит из события — строка создаётся синхронно с покупкой, и
сдвигать её значило бы подделывать аудит источника. Миллисекунду трекер не
видит вовсе: у него своё разрешение, у базы своё, и три дописанных нуля
выдали бы секундную модель за миллисекундную (исследование формата слепка).
"""
millisecond = rng.integers(0, 1000, len(purchases))
return purchases.moment.astype("datetime64[ms]") + millisecond.astype(
"timedelta64[ms]"
)
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]:
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём.
Без секунды моменты ложились бы на круглые часы, и разности времён в
аудите давали бы точные равенства там, где их в жизни не бывает.
"""
hour = pick(rng, _MOMENT_HOUR_CUMULATIVE, orders)
return hour * 3600 + rng.integers(0, 3600, orders)
def _delta(
rng: np.random.Generator, purchases: Purchases
) -> tuple[
tuple[NDArray[np.int64], ...], tuple[NDArray[np.int64], ...], NDArray[np.int64]
]:
"""Позиции заказа и сумма позиций: у выбранных заказов строкой меньше.
Бросков два, и оба на полную длину дня: признак дельты и номер позиции,
которую вычеркнул склад. Позиция выбирается равновероятно — корреляцию со
спросом на такой доле не увидеть ничем, а стоила бы она таблицей чисел
мира. Верхняя граница у каждого заказа своя, число его позиций:
`Generator.integers` транслирует массивы границ (стабильная документация
NumPy, проверено 2026-08-18), поэтому хватает одного векторного броска.
Однопозиционные заказы запрещаются маской **после** обоих бросков, а не
отбором до них.
"""
goods = catalog.catalog()
count = np.array([line.size for line in purchases.product], dtype=np.int64)
marked = rng.integers(0, 100, count.size) < world.ORDER_DELTA_PERCENT
gone = rng.integers(0, count)
dropped = marked & (count > 1)
product = list(purchases.product)
quantity = list(purchases.quantity)
items_total = purchases.revenue.copy()
for order in np.flatnonzero(dropped).tolist():
line = gone[order]
# Строка уходит целиком, вместе со своей полной стоимостью.
items_total[order] -= goods.price[product[order][line]] * quantity[order][line]
product[order] = np.delete(product[order], line)
quantity[order] = np.delete(quantity[order], line)
return tuple(product), tuple(quantity), items_total
def _discount(
purchases: Purchases, items_total: NDArray[np.int64]
) -> NDArray[np.int64]:
"""Скидка каждого заказа: процент промокода от оставшейся корзины, вниз.
Броска здесь нет: код выбрал посетитель, и он уже уехал в событие —
бэкенду остаётся прочитать таблицу. Заказ без кода скидки не получает,
а спорную копейку округление оставляет магазину. Склад сначала вычёркивает
отсутствующую позицию, поэтому скидка считается уже от `items_total`.
"""
percent = np.array(
[_DISCOUNT_PERCENT[code] if code else 0 for code in purchases.coupon],
dtype=np.int64,
)
return items_total * percent // 100
+33 -1
View File
@@ -16,6 +16,9 @@
каждая из двух кук обязана оформить заказ;
- паспорт куки — устройство и город: они у куки одни и те же во всех её
днях, а знает об этом только план (у пары один город на двоих);
- личность — `person_id` человека за кукой: у двух кук пары он один, и
заказ бэкенда показывает его как `user_id`
(docs/architecture/orders/identity.md);
- счётчики — приток по дням, дневная и накопленная аудитория, пары.
Единица дня здесь — день активности куки, а не визит: слово «визит»
@@ -92,6 +95,8 @@ class Cohort:
# Паспорт куки: номер профиля устройства и номер города в справочниках.
device: NDArray[np.int64]
city: NDArray[np.int64]
# Человек за кукой: непрозрачный ID, одинаковый у двух кук пары.
person_id: NDArray[np.uint64]
def __post_init__(self) -> None:
"""Когорта запоминается, поэтому массивы отдаются только на чтение.
@@ -132,6 +137,7 @@ class Cohort:
assigned_order=np.isin(here, ordering),
device=self.device[here],
city=self.city[here],
person_id=self.person_id[here],
)
@@ -147,6 +153,8 @@ class DayAudience:
# Паспорт куки: номера строк в справочниках устройств и городов.
device: NDArray[np.int64]
city: NDArray[np.int64]
# Человек за кукой: его заказ покажет этот ID как `user_id`.
person_id: NDArray[np.uint64]
@dataclass(frozen=True, slots=True)
@@ -200,8 +208,11 @@ def cohort(seed: int, day: int) -> Cohort:
rng, twins, active_cookie, active_day, cookies
)
# Паспорт бросается последним — после всего, что уже измерено: тогда
# счётчики канонического мира от этой добавки не двигаются.
# счётчики канонического мира от этой добавки не двигаются. По тому же
# правилу за ним приписана личность: этап 3 дописал её в конец, и куки,
# пары и паспорта остались теми же до байта.
device, city = _passports(rng, twins, cookies)
person_id = _persons(rng, twins, people, cookies)
return Cohort(
day=day,
people=people,
@@ -214,6 +225,7 @@ def cohort(seed: int, day: int) -> Cohort:
pair_order_days=pair_order_days,
device=device,
city=city,
person_id=person_id,
)
@@ -234,6 +246,7 @@ def audience(seed: int, day: int) -> DayAudience:
assigned_order=np.concatenate([part.assigned_order for part in parts]),
device=np.concatenate([part.device for part in parts]),
city=np.concatenate([part.city for part in parts]),
person_id=np.concatenate([part.person_id for part in parts]),
)
@@ -364,3 +377,22 @@ def _passports(
pick(rng, _DEVICE_HALF_CUMULATIVE[half], here.size)
]
return device, city
def _persons(
rng: np.random.Generator, twins: NDArray[np.int64], people: int, cookies: int
) -> NDArray[np.uint64]:
"""Человек за каждой кукой: у двух кук пары ID один и тот же.
Личность — минимальная форма отношения «кука принадлежит человеку», и
знает его только план: заказ спрашивает и показывает то же число как
`user_id`, а в кликстрим оно не попадает вовсе
(docs/architecture/orders/identity.md). Значение непрозрачное и живёт
ниже 2^53 — тот же потолок, что у куки: выше JSON округляет при разборе.
"""
person_id = np.empty(cookies, dtype=np.uint64)
person_id[:people] = rng.integers(1, ids.LIMIT, people, dtype=np.uint64)
# Вторая кука пары повторяет ID своего человека: заказы с двух кук —
# это и есть склейка, ради которой пары заведены.
person_id[twins[:, 1]] = person_id[twins[:, 0]]
return person_id
@@ -0,0 +1,227 @@
"""Проигрыватель: гонит дни мира в приёмник — пачкой или с темпом живого дня.
Состояния у него нет (спека генератора, раздел 9). Зерно и номер дня приходят
параметрами, позицию на оси он не хранит и из данных не выводит: её ведёт тот,
кто зовёт, — на этапе 5 это переменная Airflow у дага `next_day`. Отсюда и
переигровка обрыва: позвали тот же день заново — получили те же `WatchID`, и
дедупликация склеила повтор.
Два режима отличаются только темпом. Пакетный шлёт события подряд, без пауз, —
это заливка снимка и переигровка дня. Живой держит модельное время: событие
уезжает тогда, когда до него дошли модельные часы, поделённые на ускорение.
По умолчанию ускорение ×60 — модельные сутки за 24 реальные минуты (спека,
раздел 5): суточная волна разворачивается на глазах.
Тайминги печатаются раздельно — генерация и доставка, как требует спека
(раздел 5): это разные машины разной природы, и сложенные в одно число они
перестают что-либо говорить. Сериализация считается частью генерации: она
рождает те самые байты, которые сторожит опись.
**Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу
сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за
вчера. Правило записано здесь одно и целиком, потому что оно и есть разница
между двумя источниками: трекер шлёт сегодняшний день, бэкенд — вчерашний.
"""
import logging
import time
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import day as day_module
from clickstream_generator import orders as orders_module
from clickstream_generator import serialize
from clickstream_generator.sinks import Sink
log = logging.getLogger(__name__)
# Как часто живой режим отчитывается о ходе дня. Минута реального времени — это
# час модельного при ×60: отчёт на каждый модельный час.
REPORT_SECONDS = 60.0
@dataclass(frozen=True, slots=True)
class Played:
"""Итог прогона: сколько уехало и за сколько."""
events: int
generated_seconds: float
delivered_seconds: float
def play(
sink: Sink,
seed: int,
first_day: int,
days: int = 1,
limit: int | None = None,
speed: float | None = None,
) -> Played:
"""Проиграть `days` дней подряд начиная с `first_day` в приёмник `sink`.
`limit` — потолок событий на весь прогон: срез для того, кто смотрит на
конвейер и не хочет ждать целый день. `speed` — ускорение живого режима;
`None` означает пакетный, то есть без пауз вовсе.
"""
events = 0
generated = 0.0
delivered = 0.0
for number in range(first_day, first_day + days):
left = None if limit is None else limit - events
if left is not None and left <= 0:
break
clock = time.monotonic()
today = day_module.stream(seed, number)
payloads = serialize.events(today, limit=left)
seconds = _event_seconds(today, len(payloads))
spent = time.monotonic() - clock
generated += spent
log.info("день %d: событий %d, генерация %.1f с", number, len(payloads), spent)
clock = time.monotonic()
if speed is None:
_send(sink, payloads)
else:
_send_paced(sink, payloads, seconds, speed)
# Рубеж дня: доставка асинхронна, и без него напечатанное время
# означало бы только «события легли в очередь отправителя».
sink.flush()
spent = time.monotonic() - clock
delivered += spent
events += len(payloads)
log.info(
"день %d: отправлено %d, доставка %.1f с", number, len(payloads), spent
)
log.info(
"итого отправлено %d событий: генерация %.1f с, доставка %.1f с",
events,
generated,
delivered,
)
return Played(
events=events, generated_seconds=generated, delivered_seconds=delivered
)
def play_snapshots(sink: Sink, seed: int, first_day: int, days: int = 1) -> None:
"""Отправить слепки прогонов `days` дней подряд начиная с `first_day`.
Прогон дня D отправляет слепок дня D−1: содержимое слепка — чистая функция
зерна и дня, от момента отправки оно не зависит, а сдвиг делает живой день
обычным. На старте оси вчера нет, поэтому прогон дня 0 не отправляет
ничего.
Слепок собирается переигровкой дней своего окна: заказы дня — производная
всей воронки, дешёвого пути к ним нет. Окна соседних слепков перекрываются
почти целиком, и внутри одного прогона день играется один раз — иначе
стартовый диапазон стоил бы полусотни проигрышей вместо восьми. Между
прогонами не остаётся ничего: кэш на томе был бы состоянием, которого у
проигрывателя нет.
"""
played: dict[int, orders_module.Orders] = {}
sent = 0
generated = 0.0
delivered = 0.0
for number in range(first_day, first_day + days):
taken = number - 1
if taken < 0:
continue
clock = time.monotonic()
window = [
_orders_of(played, seed, born) for born in orders_module.window(taken)
]
payloads = serialize.orders(window, taken)
spent = time.monotonic() - clock
generated += spent
log.info(
"слепок дня %d: заказов %d, генерация %.1f с", taken, len(payloads), spent
)
clock = time.monotonic()
_send(sink, payloads)
sink.flush()
spent = time.monotonic() - clock
delivered += spent
sent += len(payloads)
log.info(
"слепок дня %d: отправлено %d, доставка %.1f с", taken, len(payloads), spent
)
log.info(
"итого отправлено %d заказов: генерация %.1f с, доставка %.1f с",
sent,
generated,
delivered,
)
def _orders_of(
played: dict[int, orders_module.Orders], seed: int, number: int
) -> orders_module.Orders:
"""Заказы дня `number`, сыгранного один раз на весь прогон."""
if number not in played:
played[number] = day_module.stream(seed, number).orders
return played[number]
def _send(sink: Sink, payloads: list[bytes]) -> None:
"""Пакетно: подряд и без пауз."""
for payload in payloads:
sink.send(payload)
def _send_paced(
sink: Sink, payloads: list[bytes], seconds: NDArray[np.int64], speed: float
) -> None:
"""С темпом: событие уезжает, когда до него дошло модельное время.
Отставание не догоняется рывком и не прячется: спешить некуда — событие
всё равно уедет, — а вот увидеть отставание в логе нужно, иначе живой
режим врёт про темп. Обгонять модельное время нельзя, отставать можно, и
именно это печатает отчёт.
"""
started = time.monotonic()
origin = int(seconds[0]) if seconds.size else 0
reported = started
lag = 0.0
for number, payload in enumerate(payloads):
due = started + (int(seconds[number]) - origin) / speed
now = time.monotonic()
if now < due:
time.sleep(due - now)
else:
lag = max(lag, now - due)
sink.send(payload)
now = time.monotonic()
if now - reported >= REPORT_SECONDS:
log.info(
"проиграно %d из %d, модельное время %s, лаг %.1f с",
number + 1,
len(payloads),
_model_time(int(seconds[number]) - origin),
lag,
)
reported = now
lag = 0.0
def _event_seconds(today: day_module.Day, count: int) -> NDArray[np.int64]:
"""Секунды событий абсолютной меткой — по ним живой режим держит темп."""
times: NDArray[np.datetime64] = today.columns["UTCEventTime"][:count]
return times.astype("datetime64[s]").astype(np.int64)
def _model_time(elapsed: int) -> str:
"""Прожитое модельное время дня в виде `ЧЧ:ММ` — от первого события."""
return f"{elapsed // 3600:02d}:{elapsed % 3600 // 60:02d}"
+12 -11
View File
@@ -4,19 +4,20 @@
1.1–1.2) и здесь не переоткрываются — модуль записывает их машинно-читаемо.
Контракт принадлежит генератору и кормит трёх потребителей: сам генератор,
его валидацию и «описание выгрузки» в доках (`schema_doc`). Хранилище
строится по описанию, а не по модулю; границу будет сторожить contract-тест,
сверяющий `system.columns` поднятого стенда с этим контрактом, — он придёт
вместе с типизированным ODS (спека генератора, раздел 3).
строится по описанию, а не по модулю; границу сторожит строгий приём на его
стороне — сверка набора ключей сообщения с контрактным списком, и
разошедшееся уходит в таблицу ошибок (спека генератора, раздел 3).
Что несёт описатель колонки:
- `name` — имя источника, как в облачной выгрузке Метрики; сырой слой хранит
его без изменений.
- `clickhouse_type` — тип в хранилище ровно в той записи, в какой его вернёт
`system.columns`: параметры входят в имя типа целиком, без сокращений
(`LowCardinality(String)`, `Array(Float64)`). Сверено 2026-08-01 —
по документации ClickHouse через Context7 и запросом к узлу стенда
(26.3.17.56); от этой записи зависит будущий contract-тест.
- `clickhouse_type` — тип колонки у источника в той записи, которой пользуется
ClickHouse: параметры входят в имя целиком, без сокращений
(`LowCardinality(String)`, `Array(Float64)`). Обычно ODS берет его без
изменений. Исключение одно: денежный `purchaseRevenue` приезжает как
Float64, а на границе хранения разбирается прямо в Decimal. Поэтому поле
описывает выгрузку, а локальный тип ODS назван в документации хранилища.
- `numpy_dtype` — чем колонка представлена внутри генератора; у массивов это
тип элемента. Строки живут в `object`-массивах: numpy-строки фиксированной
длины стенду ничего не дают.
@@ -106,7 +107,7 @@ COLUMNS: tuple[Column, ...] = (
),
Column(
name="UTCEventTime",
clickhouse_type="DateTime",
clickhouse_type="DateTime('UTC')",
numpy_dtype="datetime64[s]",
normalized_name="utc_event_time",
group=ColumnGroup.IDENTIFIERS,
@@ -370,8 +371,8 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="float64",
normalized_name="purchase_revenue",
group=ColumnGroup.ECOMMERCE,
comment="выручка заказа глазами клиента; Float64, как у Метрики —"
" на этом держится урок о расхождениях с бэкендом",
comment="выручка заказа глазами клиента; источник объявляет Float64,"
" ODS хранит точный Decimal",
),
Column(
name="purchaseCurrency",
@@ -5,7 +5,7 @@
содержание берётся из контракта (`schema`), правится только там; свежесть
документа сторожит тест.
Запуск — из корня репозитория целью `make docs`.
Запуск — из каталога генератора целью `make docs`.
"""
import argparse
@@ -18,7 +18,7 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
Документ собран из контракта схемы генератора
(`generator/src/clickstream_generator/schema.py`). Руками не править —
пересобрать: `make docs`.
пересобрать: `make docs` из каталога `generator/`.
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
@@ -27,10 +27,12 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное
имя». Это имя источника, приведённое к snake_case, а не имя атрибута в
модели данных: слой DDS складывает свою модель и называет атрибуты по ней.
Имена и типы колонок описывают сторону источника. Обычно ODS принимает их без
изменений. Исключение — `purchaseRevenue`: источник объявляет Float64, а ODS
хранит деньги в Decimal. Точный локальный тип назван в документации
хранилища. Имя в нашем стиле ждет в столбце «Нормализованное имя». Это имя
источника, приведенное к snake_case, а не имя атрибута в модели данных: слой
DDS складывает свою модель и называет атрибуты по ней.
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
контрактом.
+10 -5
View File
@@ -7,7 +7,7 @@
│ ├── ось → номер дня: когорта этого дня
│ └── предыстория → глубина: когорта дня до D0
└── дни
└── номер дня → трафик, торговля, расхождения, опоздания
└── номер дня → трафик, торговля, заказы, маячки счетчика
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
@@ -25,8 +25,8 @@ from enum import IntEnum
import numpy as np
# Каноническое зерно эталонного мира — константа репозитория; манифест хранит
# его в паспорте мира. Свои зёрна менти крутит без гарантий манифеста.
# Каноническое зерно эталонного мира — константа репозитория; опись хранит
# его в паспорте мира. Свои зёрна менти крутит без гарантий описи.
CANONICAL_SEED = 20260601
@@ -35,8 +35,13 @@ class Component(IntEnum):
TRAFFIC = 0
COMMERCE = 1
DISCREPANCIES = 2
LATECOMERS = 3
# Заказная сторона: деньги магазина и судьба заказа. Ветвится по дню
# рождения заказа — слепок несёт семь дней рождения сразу, и судьбу
# каждого заказа читает из его собственного дня
# (docs/architecture/orders/fate.md).
ORDERS = 2
# Маячки счетчика: потеря и повторная отправка событий.
BEACONS = 3
class _Branch(IntEnum):
@@ -0,0 +1,163 @@
"""Канонический сериализатор: единственное место, где событие целиком → JSON.
Правило «сериализатор один» (спека генератора, разделы 4 и 6) — не про
экономию строк, а про канон: два прогона одного дня обязаны дать те же байты,
а байты рождаются здесь. Второе место, собирающее событие руками, разошлось бы
с этим по экранированию, порядку ключей или записи чисел — и разошлось бы
молча. Граница правила проходит по событию, а не по всякому JSON: вложенный
блок `ecommerce` собирает `commerce`, и это часть содержимого колонки, а не
второй сериализатор.
Что делает канон:
- **Порядок ключей — порядок контракта схемы.** Он берётся из `schema.COLUMNS`
и нигде не повторяется: два источника порядка разъехались бы при первой же
вставке колонки.
- **Все 47 ключей всегда.** Пусто по контракту — пустое значение: пустой
массив, пустая строка, ноль. Пропавший ключ увёл бы событие в брак целиком:
строгий приём хранилища сверяет набор ключей (ADR 0005).
- **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как
`2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость
сырья: менти открывает колонку `raw` обычным клиентом и разбирает событие
глазами, а число эпохи этот урок убивает.
- **Одно событие — один документ JSON**, без перевода строки внутри: приёмник
сам решает, чем их разделить.
Колонки переводятся в питоновские значения целиком, а не по строкам: numpy
делает это одним вызовом на колонку, и на дне в полсотни тысяч событий разница
заметна. Обратная сторона — день лежит в памяти дважды; проигрыватель поэтому
и берёт его днями, а не горизонтом целиком.
**Второй контракт провода — слепок заказов** (мастер-спека, раздел 2). Он не
похож на событие: одиннадцать ключей вместо сорока семи, деньги строками, а
не числами, времена с миллисекундами. Общее у них одно, зато главное: байты
рождаются здесь и только здесь. Запись слепка — один словарь с вложенным
списком и один `orjson.dumps`.
"""
from collections.abc import Sequence
from datetime import timedelta
from typing import Any
import numpy as np
import orjson
from numpy.typing import NDArray
from clickstream_generator import catalog, schema, world
from clickstream_generator.day import Day
from clickstream_generator.orders import Orders, arrived, at_boundary
_ARRAY_PREFIX = "Array("
def events(day: Day, limit: int | None = None) -> list[bytes]:
"""Канонические байты событий дня: по документу JSON на событие.
`limit` берёт первые события дня и на этом останавливается — срез для
того, кто смотрит на конвейер и не хочет ждать целый день (спека,
раздел 9). Ограничение считается до сериализации: платить за то, что не
поедет, незачем.
"""
count = len(day) if limit is None else min(limit, len(day))
names = tuple(column.name for column in schema.COLUMNS)
values = [
_values(column, day.columns[column.name][:count]) for column in schema.COLUMNS
]
return [
orjson.dumps(dict(zip(names, row, strict=True)))
for row in zip(*values, strict=True)
]
def orders(window: Sequence[Orders], day: int) -> list[bytes]:
"""Канонические байты слепка дня `day`: по документу JSON на заказ.
`window` — заказы дней окна, от раннего дня к позднему. Уже приехавшие
заказы слепок несёт подряд, поэтому порядок строк остаётся порядком
рождения, он же возрастание `order_id`. Какие это дни, решает
`orders.window`.
Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они
станут `Decimal`, и дробь двоичного числа была бы потерей точности до
всякого разбора. Времена — метки UTC с миллисекундами; `snapshot_date`
одинакова во всей выгрузке — это дата дня, состояние которого снято.
"""
goods = catalog.catalog()
sku = goods.sku.tolist()
prices = [_money(price) for price in goods.price.tolist()]
snapshot_date = (world.ORIGIN + timedelta(days=day)).isoformat()
payloads = []
for rows in window:
status, updated = at_boundary(rows, day)
created_at = _moments(rows.created_at)
updated_at = _moments(updated)
user_id = rows.user_id.tolist()
items_total = rows.items_total.tolist()
discount = rows.discount.tolist()
delivery = rows.delivery.tolist()
total = rows.total.tolist()
for number in np.flatnonzero(arrived(rows, day)).tolist():
order_id = rows.order_id[number]
payloads.append(
orjson.dumps(
{
"order_id": order_id,
"user_id": user_id[number],
"status": status[number],
"created_at": created_at[number],
"updated_at": updated_at[number],
"items_total": _money(items_total[number]),
"discount": _money(discount[number]),
"delivery": _money(delivery[number]),
"total": _money(total[number]),
"items": [
{"sku": sku[item], "qty": count, "price": prices[item]}
for item, count in zip(
rows.product[number].tolist(),
rows.quantity[number].tolist(),
strict=True,
)
],
"snapshot_date": snapshot_date,
}
)
)
return payloads
def _money(kopecks: int) -> str:
"""Неотрицательные копейки — строкой с двумя знаками: `129990` → `1299.90`."""
if kopecks < 0:
raise ValueError(f"деньги не могут быть отрицательными: {kopecks}")
return f"{kopecks // 100}.{kopecks % 100:02d}"
def _moments(values: NDArray[np.datetime64]) -> list[str]:
"""Метки времени — строками RFC 3339 в UTC с миллисекундами.
Три знака стоят всегда, в том числе `.000`: одинаковая длина дробной части
и одинаковая зона дают хронологическую сортировку простым сравнением строк,
а разбор в хранилище идёт по точному шаблону.
"""
ms: NDArray[np.datetime64] = values.astype("datetime64[ms]")
return np.datetime_as_string(ms, unit="ms", timezone="UTC").tolist()
def _values(column: schema.Column, values: NDArray[Any]) -> list[Any]:
"""Колонка питоновскими значениями — в той записи, в какой уедет на провод.
Массив узнаётся по типу ClickHouse, а не по `numpy_dtype`: у колонки-массива
там записан тип элемента (`uint32`), и от скалярной колонки её этим не
отличить.
"""
if column.clickhouse_type.startswith(_ARRAY_PREFIX):
# Колонка-массив: в ячейке лежит свой массив, пустой у события,
# которому эта колонка не по смыслу.
return [cell.tolist() for cell in values]
if column.numpy_dtype == "datetime64[D]":
return np.datetime_as_string(values, unit="D").tolist()
if column.numpy_dtype == "datetime64[s]":
return np.datetime_as_string(values, unit="s", timezone="UTC").tolist()
return values.tolist()
@@ -0,0 +1,137 @@
"""Приёмники: куда уезжают канонические байты. Про содержимое они не знают.
Приёмник глуп по замыслу (спека генератора, раздел 4): он берёт готовый байт
события и доставляет его. Ни формы, ни темпа он не решает — форму задал
сериализатор, темп задаёт проигрыватель. Отсюда и весь их интерфейс: принять
событие, дождаться принятого, закрыться.
Приёмников два, а режимов три: «Kafka пачкой» и «Kafka с темпом ×60»
различаются не приёмником, а тем, кто его зовёт. Ускорять доставку, зная о
времени события, значило бы вернуть приёмнику знание о содержимом — ровно то,
чего правило не хочет.
**Ключа у сообщения Kafka нет** — решение тикета #41, и вот довод. `WatchID`
уникален у каждого события, поэтому ключом он не был бы ключом: обещание Kafka
про ключ — «сообщения одного ключа лежат в одном разделе и сохраняют порядок», а
у ряда, где ключи не повторяются, обещать нечего. Читатель же прочёл бы такой
ключ как смысл, которого в нём нет. Настоящий ключ здесь — `ClientID`
(события одной куки по порядку), и он тикетом не назначен: раскладку по
разделам стенд намеренно оставляет транспорту (спека, раздел 2), а лаба
«какая нода читала топик» живёт как раз тем, что раскладка не предрешена.
Раздел для сообщения без ключа librdkafka выбирает случайно, но подряд идущие
сообщения на десяток миллисекунд липнут к одному — измеренные умолчания и их
следствия записаны в спеке (раздел 9).
"""
from pathlib import Path
from typing import Any, Protocol
# Сколько ждать разбора очереди отправителя, когда она заполнилась, и сколько —
# доставки остатка при закрытии. Оба числа — потолок ожидания, а не пауза:
# обычно ждать не приходится вовсе.
QUEUE_WAIT_SECONDS = 1.0
FLUSH_WAIT_SECONDS = 60.0
class Sink(Protocol):
"""Приёмник: принимает байты события и доводит их до места."""
def send(self, payload: bytes) -> None:
"""Принять одно событие."""
def flush(self) -> None:
"""Дождаться, пока принятое дойдёт до места.
Нужно не только при закрытии: доставка асинхронна, и без этого рубежа
«доставка 0,1 с» в логе означала бы лишь то, что события успели лечь в
очередь отправителя. Проигрыватель ставит рубеж в конце каждого дня.
"""
def close(self) -> None:
"""Закрыть приёмник, дождавшись всего принятого."""
class FileSink:
"""Файл: одно событие — одна строка.
Построчность — контракт файла, а не удобство: файл читают построчно, и
склейка двух событий в строку сломала бы разбор целиком. Файл — кэш чистой
функции (спека, раздел 4): потерял — пересчитал, поэтому места в
репозитории ему не отведено.
"""
def __init__(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
self._file = path.open("wb")
def send(self, payload: bytes) -> None:
self._file.write(payload + b"\n")
def flush(self) -> None:
self._file.flush()
def close(self) -> None:
self._file.close()
class KafkaSink:
"""Kafka: одно событие — одно сообщение.
«Пачкой» относится к темпу отправки, а не к упаковке: события идут подряд
без пауз, но каждое отдельным сообщением (спека, раздел 4). Отправка
асинхронная — отправитель копит сообщения в своей очереди и шлёт их
пачками сам; отсюда `poll`, который отдаёт нам отчёты о доставке, и
`flush`, без которого хвост очереди уехал бы в никуда вместе с процессом.
"""
def __init__(self, brokers: str, topic: str) -> None:
# Импорт внутри: клиент — необязательная часть пакета, и без него
# генератор пишет в файл (спека, раздел 9).
from confluent_kafka import Producer
self._topic = topic
self._failure: str | None = None
self._producer = Producer({"bootstrap.servers": brokers})
def send(self, payload: bytes) -> None:
while True:
try:
self._producer.produce(
self._topic, value=payload, on_delivery=self._report
)
break
except BufferError:
# Очередь отправителя полна: ждём, пока брокер её разберёт.
# `poll` здесь и работа, и пауза — он же отдаёт отчёты.
self._producer.poll(QUEUE_WAIT_SECONDS)
self._producer.poll(0)
self._raise_failure()
def flush(self) -> None:
remaining = self._producer.flush(FLUSH_WAIT_SECONDS)
if remaining:
raise RuntimeError(
f"Kafka не приняла {remaining} сообщений за"
f" {FLUSH_WAIT_SECONDS:.0f} с: брокер недоступен или не успевает"
)
self._raise_failure()
def close(self) -> None:
# Закрывать у отправителя нечего — важно лишь не бросить хвост
# очереди: он уехал бы в никуда вместе с процессом.
self.flush()
def _report(self, error: Any, message: Any) -> None:
"""Отчёт о доставке: первую неудачу запоминаем, остальные не важны."""
if error is not None and self._failure is None:
self._failure = str(error)
def _raise_failure(self) -> None:
"""Неудачную доставку превращаем в остановку прогона.
Молча потерянное сообщение — худший исход: счёт в хранилище разойдётся
с числом отправленного, а причина будет забыта.
"""
if self._failure is not None:
raise RuntimeError(f"Kafka не приняла сообщение: {self._failure}")
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more