diff --git a/CONTEXT.md b/CONTEXT.md index 92509db..106c192 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -11,6 +11,12 @@ источников, строится по их документации. _Избегать_: склад, склад данных +**Сырые данные**: +Слой STG хранилища и его содержимое: сообщение топика, положенное строкой, +как пришло, без разбора. В прозе — сырые данные и сырой слой; про +конкретную таблицу или базу — STG. +_Избегать_: сырьё + **Состав мира**: Постоянная часть мира генератора — популяция посетителей, их привычки, двухкуковые пары. По дням его выдаёт план состава. @@ -122,13 +128,13 @@ ClickHouse. Форма файла решена, длина — нет: стро на нем принимаются следующие этапы. _Избегать_: зерновой мир -**Опись мира**: -`data/world-inventory.json` — единственное, что о мире хранится в git: паспорт -мира (зерно, версия генератора, хеш каталога), строка на каждый день событий +**Паспорт мира**: +`data/world-inventory.json` — единственное, что о мире хранится в git: шапка +(зерно, версия генератора, хеш каталога), строка на каждый день событий (число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов). Счётчик сверх этого заводится только с названным читателем. Сам мир не -хранится, а пересчитывается; опись отвечает на один вопрос: тот ли это мир. -_Избегать_: манифест, мини-манифест +хранится, а пересчитывается; паспорт отвечает на один вопрос: тот ли это мир. +_Избегать_: опись мира, манифест, мини-манифест **Пошаговый режим**: Базовый способ движения по оси модельного времени: «прожить следующий @@ -137,16 +143,23 @@ _Избегать_: манифест, мини-манифест **Живой день**: Проигрывание текущего модельного дня в реальном времени с ускорением; включается по требованию — постоянным фоном идёт, только пока включён -выключатель пульта. +выключатель `world_live`. **Пакетный режим**: Проигрывание готового дня пачкой, без темпа: заливка снимка при старте стенда, пересборки и проверки. -**Пульт мира**: -Даги Airflow, которыми двигают ось модельного времени: работники играют день — -пачкой или в темпе, — а выключатель держит стенд живущим, пока включён. -_Избегать_: управляющие даги +**Даги модельного времени**: +Даги Airflow, которыми двигают ось модельного времени: шаговые даги играют +день — пачкой или в темпе, — а выключатель держит стенд живущим, пока включён. +Коротко — даги времени. +_Избегать_: пульт мира, управляющие даги + +**Шаговый даг**: +Даг модельного времени, который играет день и ставит позицию на оси: +`world_next_day` — пачкой, `world_live_day` — в темпе. Запускается руками, +расписания и паузы не имеет; автоматику поверх него даёт выключатель. +_Избегать_: работник **Граница суток**: Единственный структурный шов модели: сессии режутся по ней, слепок заказов @@ -211,6 +224,6 @@ _Избегать_: путать с приёмником и пакетным р генератора, про отправку; забор — про сторону хранилища, про чтение. **Техническая проверка**: -Проверка, чей оракул — внутренний эталон стенда (опись мира), которого у +Проверка, чей оракул — внутренний эталон стенда (паспорт мира), которого у настоящего магазина нет. За учебный урок DQ не выдаётся. _Избегать_: DQ-проверка — та работает на наблюдаемых данных магазина. diff --git a/README.md b/README.md index 580eff9..5aa521e 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,12 @@ Prometheus, Grafana, экспортёр метрик Kafka и общая баз пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не хватает, стенд одинаково хорошо живёт на недорогом VPS. -Проверкам на поднятом стенде также нужны `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` в корне, -всем целям генератора в `generator/` — нужен `uv`. +Проверкам на поднятом стенде также нужны `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` в корне, всем +целям генератора в `generator/` — нужен `uv`. Сначала скопируйте настройки стенда: @@ -61,7 +61,8 @@ make check-services экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь, -кроме [пульта мира](docs/architecture/world.md#пульт-мира). `compose.yaml` не +кроме [дагов модельного +времени](docs/architecture/world.md#даги-модельного-времени). `compose.yaml` не дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит скопировать образец. Внутренние адреса, имена топиков и версии образов описывают сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile. @@ -79,28 +80,28 @@ make check-services поднятый стенд, сколько стоит прогон и куда класть новую — в [карте проверок](docs/architecture/testing.md). -Остановить контейнеры без удаления данных можно командой `make down`. Если -нужно вернуть прикладной мир к началу, `make rebuild-storage` запустит в -Airflow его явную пересборку: удалит прикладные базы ClickHouse, топики и -позиции потребителей Kafka, затем создаст схему и данные заново. Службы и их -тома останутся на месте. `make clean` удаляет весь стенд вместе с именованными -томами и для обычной разработки не нужен. Перед пересборкой поставьте -`world_live` на паузу и дайте уже начатому работнику закончить день. +Остановить контейнеры без удаления данных можно командой `make down`. Если нужно +вернуть прикладной мир к началу, `make rebuild-storage` запустит в Airflow его +явную пересборку: удалит прикладные базы ClickHouse, топики и позиции +потребителей Kafka, затем создаст схему и данные заново. Службы и их тома +останутся на месте. `make clean` удаляет весь стенд вместе с именованными томами +и для обычной разработки не нужен. Перед пересборкой поставьте `world_live` на +паузу и дайте уже начатому шаговому дагу закончить день. ## Стартовый мир -Стартовый мир и его опись описаны в -[справочнике мира](docs/architecture/world.md). +Стартовый мир и его паспорт описаны в [справочнике +мира](docs/architecture/world.md). ## Как позвать генератор Режимы и команды запуска генератора описаны в [справочнике мира](docs/architecture/world.md#как-запустить-генератор). -### Пульт мира +### Даги модельного времени -Даги пульта, позиция мира и форма запуска описаны в -[справочнике мира](docs/architecture/world.md#пульт-мира). +Даги времени, позиция мира и форма запуска описаны в [справочнике +мира](docs/architecture/world.md#даги-модельного-времени). ## Состав и доступ diff --git a/compose.yaml b/compose.yaml index 885699d..df87088 100644 --- a/compose.yaml +++ b/compose.yaml @@ -34,7 +34,7 @@ x-clickhouse-common: &clickhouse-common retries: 30 start_period: 10s -# Факты генератора и пульта мира названы по одному разу — иначе они однажды +# Факты генератора и дагов модельного времени названы по одному разу — иначе они однажды # разойдутся, и заметит это не проверка, а менти с пустым топиком. x-generator-image: &generator-image clickstream-generator:local x-orders-topic: &orders-topic orders @@ -70,7 +70,7 @@ x-airflow-common: &airflow-common 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 @@ -291,7 +291,7 @@ services: airflow-init: condition: service_completed_successfully command: scheduler - # Пульт мира зовёт генератор отдельным контейнером, а при LocalExecutor + # Даги времени зовут генератор отдельным контейнером, а при LocalExecutor # задачи исполняет сам планировщик — значит сокет докера нужен ему одному. # Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root # на машине. Открывает дверь не монтирование, а группа: у сокета права 660 diff --git a/dags/dds_transform.py b/dags/dds_transform.py index 59c4793..82bb987 100644 --- a/dags/dds_transform.py +++ b/dags/dds_transform.py @@ -46,8 +46,8 @@ HITS_PARTITIONS = (0, 1) READINESS_TIMEOUT_SECONDS = 300 READINESS_POLL_SECONDS = 3 -# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит -# пульт мира, и только она отличает прожитый день от живого — зачем это +# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставят +# даги модельного времени, и только она отличает прожитый день от живого — зачем это # сессиям, разобрано в sql/dds/session_scope.sql. WORLD_POSITION = "world_position" @@ -224,8 +224,8 @@ def _wait_for_phase( def _world_position() -> int: """Позиция мира числом, с внятным отказом на непригодном значении. - Ту же переменную читает пульт, но общего модуля у дагов нет намеренно: - связь между пультом и трансформацией держит сама переменная, а не импорт. + Ту же переменную читают даги времени, но общего модуля у дагов нет намеренно: + связь между дагами времени и трансформацией держит сама переменная, а не импорт. """ raw_position = Variable.get(WORLD_POSITION, default=None) if raw_position is None: diff --git a/dags/dq_check.py b/dags/dq_check.py index c2323ea..73b9cda 100644 --- a/dags/dq_check.py +++ b/dags/dq_check.py @@ -148,7 +148,7 @@ def build_classes_vs_inventory() -> list[str]: for mismatch_class, count in day["orders"].items() } if not expected: - raise AirflowException("classes_vs_inventory: в описи нет закрытых дней") + raise AirflowException("classes_vs_inventory: в паспорте нет закрытых дней") query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8") # GROUP BY закономерно не возвращает группу из нуля строк. diff --git a/dags/orders_ingest.py b/dags/orders_ingest.py index c157e65..7f81363 100644 --- a/dags/orders_ingest.py +++ b/dags/orders_ingest.py @@ -1,9 +1,9 @@ """Приём заказов: пакетный забор слепка из Kafka в хранилище. Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор -порции в сырьё, затем разбор того же среза в типизированный ODS и в таблицу +порции в `stg`, затем разбор того же среза в типизированный ODS и в таблицу брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, — -работник пульта мира после проигрыша дня, — и ждёт конца. +шаговый даг модельного времени после проигрыша дня, — и ждёт конца. Контраст с приёмом событий и есть урок. События тянет матвью, навсегда подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов diff --git a/dags/world_control.py b/dags/world_control.py index 7574731..1b6fb9d 100644 --- a/dags/world_control.py +++ b/dags/world_control.py @@ -1,25 +1,25 @@ -"""Пульт мира: даги, которыми двигают ось модельного времени. +"""Даги модельного времени: ими двигают ось мира. -Их три, и они двух родов. **Работники** играют день и запускаются руками: +Их три, и они двух родов. **Шаговые даги** играют день и запускаются руками: `world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` — один день в темпе модельного времени. **Выключатель** `world_live` своей работы -не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь +не делает: он тикает по расписанию и дёргает шаговый даг живого дня, дожидаясь конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на границе модельных суток. Сыграть день — половина работы. Вторая половина: отправить слепок заказов и -дождаться, пока хранилище его заберёт. Поэтому у обоих работников за проигрышем +дождаться, пока хранилище его заберёт. Поэтому у обоих шаговых дагов за проигрышем идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма `orders_ingest`. Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка только разгоняет источники: слои после нее менти догоняет ручным запуском. -Разделение не косметическое. Расписание на самом работнике заставило бы кнопку -паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник +Разделение не косметическое. Расписание на самом шаговом даге заставило бы кнопку +паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а шаговый даг при этом выглядел бы в списке выключенным, хотя нажимают его каждый день. -Календарь Airflow к оси мира отношения не имеет: какой день играть, работник +Календарь Airflow к оси мира отношения не имеет: какой день играть, шаговый даг спрашивает у переменной, а не у логической даты прогона. Решения и доводы целиком — ADR 0009. @@ -51,7 +51,7 @@ ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"] STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"]) # Позиция на оси: номер первого несыгранного дня. До конца инициализации -# работники не имеют права угадывать её — состояние объяснено в ADR 0013. +# шаговые даги не имеют права угадывать её — состояние объяснено в ADR 0013. # # Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее # при таком правиле — сыгранный дважды день, а повтор схлопнет @@ -63,12 +63,12 @@ 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 = ["пульт мира"] +TAGS = ["модельное время"] @task @@ -120,7 +120,7 @@ def _generator(task_id: str, command: list[str]) -> DockerOperator: def _ingest_orders() -> TriggerDagRunOperator: """Задача забора: дёрнуть даг приёма и дождаться, чем он кончился. - Ожидание здесь несущее. Без него работник позеленел бы, не узнав, доехал + Ожидание здесь несущее. Без него шаговый даг позеленел бы, не узнав, доехал ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа не должен переживать отказ выше (ADR 0003). """ @@ -128,7 +128,7 @@ def _ingest_orders() -> TriggerDagRunOperator: task_id="trigger_orders_ingest", trigger_dag_id="orders_ingest", wait_for_completion=True, - # Умолчание — минута опроса, а весь прогон работника пачкой короче. + # Умолчание — минута опроса, а весь прогон шагового дага пачкой короче. poke_interval=10, ) @@ -151,7 +151,7 @@ def _ingest_orders() -> TriggerDagRunOperator: # (params.rst, Airflow 3.3), чтобы правило было видно сразу. description_md=( "Форма задает только число дней. Номер первого несыгранного " - "дня работник берет из переменной `world_position` (Admin → " + "дня шаговый даг берёт из переменной `world_position` (Admin → " "Variables). Поля Logical date и Run ID нужны самому Airflow " "и на выбор дня не влияют." ), @@ -163,7 +163,7 @@ def world_next_day(): Запускается руками. День по умолчанию один, но разгон вперёд идёт одним нажимом, а не десятью: сколько дней играть — параметр запуска. Первый - день работник берет из `world_position`. Логическая дата прогона на выбор + день шаговый даг берёт из `world_position`. Логическая дата прогона на выбор дня не влияет. """ @@ -215,7 +215,7 @@ def world_live_day(): Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре реальные минуты, и суточная волна разворачивается на глазах. Запускается руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`. - Следующий день работник берет из `world_position`. Логическая дата прогона + Следующий день шаговый даг берёт из `world_position`. Логическая дата прогона на выбор дня не влияет. """ diff --git a/dags/world_lifecycle.py b/dags/world_lifecycle.py index 130a73a..29465bd 100644 --- a/dags/world_lifecycle.py +++ b/dags/world_lifecycle.py @@ -185,7 +185,7 @@ def world_initialize(): @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"] diff --git a/docs/adr/0005-event-ingestion.md b/docs/adr/0005-event-ingestion.md index 8f1ec33..ee50374 100644 --- a/docs/adr/0005-event-ingestion.md +++ b/docs/adr/0005-event-ingestion.md @@ -2,6 +2,10 @@ Дата: 3 августа 2026 года. Статус: принято. +Позднее тикет #214 сменил имя слоя: вместо «сырья» — сырые данные, сырой слой, +STG. Текст ниже оставлен в словах своего времени, действующее имя — в +`CONTEXT.md`. + ## Решение Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение diff --git a/docs/adr/0008-order-ingestion.md b/docs/adr/0008-order-ingestion.md index 1e41e51..d1397bf 100644 --- a/docs/adr/0008-order-ingestion.md +++ b/docs/adr/0008-order-ingestion.md @@ -3,6 +3,10 @@ Дата: 12 августа 2026 года. Статус: частично заменено [ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93. +Позднее тикет #214 сменил имена: «сырьё» стало сырыми данными, «работник» — +шаговым дагом. Текст ниже оставлен в словах своего времени, действующие +имена — в `CONTEXT.md`. + Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема diff --git a/docs/adr/0009-world-control.md b/docs/adr/0009-world-control.md index 3757858..cdcc864 100644 --- a/docs/adr/0009-world-control.md +++ b/docs/adr/0009-world-control.md @@ -2,6 +2,10 @@ Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79. +Позднее тикет #214 сменил имена: «пульт мира» стал дагами модельного времени, +«работник» — шаговым дагом. Текст ниже оставлен в словах своего времени, +действующие имена — в `CONTEXT.md`. + ## Решение Ось модельного времени двигают даги пульта, и они двух родов. diff --git a/docs/architecture/dds/event.md b/docs/architecture/dds/event.md index a7cd40d..a376961 100644 --- a/docs/architecture/dds/event.md +++ b/docs/architecture/dds/event.md @@ -77,9 +77,9 @@ ODS; точный тип и проверка описаны в разделе - **`Sign`** — колонка формата без механики: генератор исправлений не шлёт, и в модели события колонке ответить нечего. Честный рассказ о ней живёт в DDL ODS и лекции. -- **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы - `purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри - колонки читает ODS. +- **`ecommerce`** — сырой JSON из STG. Это содержимое источника; отдельным + атрибутом модели оно не становится. Разобранные массивы `purchase_*` и + `product_*` в контракте есть; лаба про разбор JSON внутри колонки читает ODS. - **`_load_ts`** — у представления нет собственной загрузки, а чужие координаты слой не переносит (правило слоя — [модель заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по diff --git a/docs/architecture/dds/order.md b/docs/architecture/dds/order.md index e1b4007..1216412 100644 --- a/docs/architecture/dds/order.md +++ b/docs/architecture/dds/order.md @@ -75,12 +75,12 @@ ODS: это единственный устойчивый ключ сущнос **служебные колонки — координаты собственной загрузки слоя; чужие координаты спрашиваются у их слоя по ключу сущности**. -Происшествие качества данных расследуется цепочкой, где каждый шаг -спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой -запуск собрал день; по `order_id` ODS показывает физические версии и брак — -там свой `_load_id` приёма связывает годные строки, ошибки и сырьё; дальше -сырьё STG, пока живо. Окно расследования задано сроками хранения: сырьё — -трое суток, брак — месяц ([дока хранилища](../storage.md)). +Происшествие качества данных расследуется цепочкой, где каждый шаг спрашивает +свой слой на его языке: по `_load_id` строки DDS видно, какой запуск собрал +день; по `order_id` ODS показывает физические версии и брак — там свой +`_load_id` приёма связывает годные строки, ошибки и данные STG; дальше читают +STG, пока данные живы. Окно расследования задано сроками хранения: сырые данные +— трое суток, брак — месяц ([дока хранилища](../storage.md)). ## Читатели diff --git a/docs/architecture/dm.md b/docs/architecture/dm.md index f297591..452a68a 100644 --- a/docs/architecture/dm.md +++ b/docs/architecture/dm.md @@ -176,9 +176,10 @@ Distributed-таблица вернула бы каждый непарный з `uniqExact(VisitID)` из `ods.event_v`, объект — число строк `dds.session_v`; - `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из - `orders` в описи мира, объект — число заказов класса в + `orders` в паспорте мира, объект — число заказов класса в `dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`, - `duplicate_event` и `amount_delta` только для покрытых описью закрытых дней; + `duplicate_event` и `amount_delta` только для покрытых паспортом закрытых + дней; - `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются `orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`; - `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые @@ -194,14 +195,14 @@ Distributed-таблица вернула бы каждый непарный з сопоставления деловых ключей. `classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она -подтверждает, что до витрины доехал записанный в описи мир, но не проверяет +подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет сам генератор: у настоящего магазина такого оракула нет. -Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow -только сводку и малый срез диагностики. Техническая сверка с описью читает -небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их -успеха даг заменяет проверенные дневные партиции. Затем именованные задачи -читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает +Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только +сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой +файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг +заменяет проверенные дневные партиции. Затем именованные задачи читают +опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает сохраниться до красного завершения, а ошибка сборки не затирает прежний результат. Красная задача печатает не больше двадцати нарушенных ключей за проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки diff --git a/docs/architecture/etl.md b/docs/architecture/etl.md index ceff7ff..0a86a47 100644 --- a/docs/architecture/etl.md +++ b/docs/architecture/etl.md @@ -37,13 +37,13 @@ world_next_day ─────> источники без ETL - **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт, упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но - зеленеть, не узнав, разложился ли день, работник не должен - ([ADR 0003](../adr/0003-dag-run-state.md)). - Такой отказ чинится перезапуском `etl_pipeline` рукой — перезапуску ничего - не нужно помнить, см. «Объём прогона». -- **Разгон пачкой.** `world_next_day` конвейер не дёргает: пульт управляет - генератором и только им. После разгона менти запускает конвейер сам — - и видит, как слои догоняют уехавший вперёд мир. + зеленеть, не узнав, разложился ли день, шаговый даг не должен ([ADR + 0003](../adr/0003-dag-run-state.md)). Такой отказ чинится перезапуском + `etl_pipeline` рукой — перезапуску ничего не нужно помнить, см. «Объём + прогона». +- **Разгон пачкой.** `world_next_day` конвейер не дёргает: даги модельного + времени управляют генератором и только им. После разгона менти запускает + конвейер сам — и видит, как слои догоняют уехавший вперёд мир. - **Рождение мира.** `world_initialize` дёргает конвейер последним шагом: `make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки ручного запуска начинаются со следующих дней. @@ -145,9 +145,9 @@ ODS и ещё не собранный в DDS, затем при необходи ## Отклонённые варианты -- **Задачи трансформаций прямо в работниках пульта.** Один зелёный прогон — - весь день, но пульт — про ось времени: работников два, шаги дублировались - бы, а этапу 5 пришлось бы выдирать их обратно. +- **Задачи трансформаций прямо в шаговых дагах.** Один зелёный прогон — + весь день, но даги времени отвечают за ось времени: шаговых дагов два, шаги + дублировались бы, а этапу 5 пришлось бы выдирать их обратно. - **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом режиме слои копили бы отставание молча. @@ -163,8 +163,8 @@ ODS и ещё не собранный в DDS, затем при необходи - [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не переживает отказ выше: отсюда ожидания в триггерах. -- [ADR 0009](../adr/0009-world-control.md) — пульт мира: его работник - живого дня и дёргает конвейер. +- [ADR 0009](../adr/0009-world-control.md) — даги модельного времени: их шаговый + даг живого дня и дёргает конвейер. - [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему прикладным процессом владеет Airflow и почему `world_initialize` кончается собранными слоями. diff --git a/docs/architecture/generator/seeds.md b/docs/architecture/generator/seeds.md index 3fe3f43..5f42ea5 100644 --- a/docs/architecture/generator/seeds.md +++ b/docs/architecture/generator/seeds.md @@ -42,7 +42,7 @@ Новый бросок в середине функции сдвигает все последующие значения этого подпотока. Код продолжает работать, но тем же свойствам достается другая случайность. Соседние подпотоки остаются прежними, а измененная часть -канонического мира проявляется в описи и побайтовом сравнении. +канонического мира проявляется в паспорте и побайтовом сравнении. Какое обещание воспроизводимости это защищает, записано в [спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно diff --git a/docs/architecture/monitoring/data.md b/docs/architecture/monitoring/data.md index 72e1b1b..0c0a30c 100644 --- a/docs/architecture/monitoring/data.md +++ b/docs/architecture/monitoring/data.md @@ -19,23 +19,23 @@ ## Почему слои у панелей разные Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него -длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань -разбор — свежесть ODS это покажет, свежесть сырья нет. +длиннее: в STG данные привозит забор, а типизированную строку кладёт разбор. +Встань разбор — свежесть ODS это покажет, а свежесть STG — нет. Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос здесь про доставку. Местом диагностики доставленных версий физическая таблица названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`). -Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего -не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, -и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем +Поток и брак считаются по сырому слою. Он хранит каждую доставку и ничего не +схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, и +все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем сильнее, чем дольше живёт стенд. Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не -отдельная куча, а часть сырья, которая не прошла разбор. +отдельная куча, а часть сырых данных, которая не прошла разбор. -Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое +Окно у панели брака — окно дашборда, и это не косметика. Сырые данные живут трое суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток приехавшего и однажды перевалила бы за сотню процентов. @@ -51,7 +51,7 @@ укладываются в минуты реального времени, и соседние дни мира помещаются в одно окно панели сами. -## Приземлившееся считают по слою сырья +## Приземлившееся считают в сыром слое Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka` @@ -66,7 +66,7 @@ Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse. Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со -стороны ClickHouse — `count()` строк сырья по тому же интервалу. +стороны ClickHouse — `count()` строк STG по тому же интервалу. Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase` растягивает приращение на всё окно и на минутном интервале промахивается на @@ -87,11 +87,11 @@ Kafka» приходит из Prometheus, ряд «приземлилось в C Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в один столбец не значит ничего. -Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден -сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему -другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него -отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё -он приезжает полем `snapshot_date`. +Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден сразу. +Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему другой — не +«сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него отвечает своя +панель, и модельный день она берёт из самого сообщения: в STG он приезжает полем +`snapshot_date`. ## Что проверено diff --git a/docs/architecture/orders/README.md b/docs/architecture/orders/README.md index 9193503..e18dfda 100644 --- a/docs/architecture/orders/README.md +++ b/docs/architecture/orders/README.md @@ -30,9 +30,9 @@ `ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы, пересечения выходят арифметикой, приоритет классов работает по-настоящему — [классы расхождений и опоздание](fate.md). -- **Опись хранит только то, чего движение мира не меняет**: хеш байтов - каждого слепка и счётчики наблюдаемых классов — - [что хранит опись](inventory.md). +- **Паспорт хранит только то, чего движение мира не меняет**: хеш байтов + каждого слепка и счётчики наблюдаемых классов — [что хранит + паспорт](inventory.md). - **Мост к склейке**: план состава владеет человеком; его непрозрачный `person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным — [мост к склейке](identity.md). diff --git a/docs/architecture/orders/fate.md b/docs/architecture/orders/fate.md index 89b4367..9b8454e 100644 --- a/docs/architecture/orders/fate.md +++ b/docs/architecture/orders/fate.md @@ -8,18 +8,18 @@ **Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа, решённая при его рождении и уложенная в окно K целиком. -**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, -дельта, опоздание. Событийная сторона — порча событийного потока: потеря и -дубль. Довод за разделение — различимость по описи: правка заказной механики -не двигает хеши событий, правка событийной не двигает байты слепка, и по -покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES` -и `LATECOMERS` решения не переживают — они названы по классам витрины, а -компонент называет часть мира; новые стороны занимают те же позиции. -Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2; -событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо -отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка -событийной механики молча меняла бы байты слепка; *компонент на класс* — -пять имён под ручки калибровки, которые крутятся разом. +**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, дельта, +опоздание. Событийная сторона — порча событийного потока: потеря и дубль. Довод +за разделение — различимость по паспорту: правка заказной механики не двигает +хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам +видно, какую сторону трогали. Прежние имена `DISCREPANCIES` и `LATECOMERS` +решения не переживают — они названы по классам витрины, а компонент называет +часть мира; новые стороны занимают те же позиции. Заказная сторона названа при +реализации — подпоток `ORDERS` на позиции 2; событийная — `BEACONS` на позиции +3: маячок счетчика либо не долетает, либо отправляется повторно. Отклонено: +*один компонент на всю судьбу* — правка событийной механики молча меняла бы +байты слепка; *компонент на класс* — пять имён под ручки калибровки, которые +крутятся разом. **Правило формы, без которого разделение не работает: броски заказной стороны делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска @@ -62,15 +62,14 @@ процентов» не заводим. **Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя -версия; различает их история версий: сырьё STG и физические версии -`ods.order` до фоновых слияний, а в витринах — выручка дня, которая -сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние -на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у -сильно опоздавших, приехавших уже терминальными. Точную форму этого урока -решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда -деться, кроме как остаться брошенным; *мгновенная отмена при рождении* — -«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1, -он в резерве. +версия; различает их история версий: строки STG и физические версии `ods.order` +до фоновых слияний, а в витринах — выручка дня, которая сначала выросла, потом +убыла. Полное различение не обещано: слепок — состояние на границе суток, и +оплата с отменой в один день в сырых данных неразличимы; то же у сильно +опоздавших, приехавших уже терминальными. Точную форму этого урока решает этап +4. Отклонено: *отмена только после оплаты* — неоплаченному некуда деться, кроме +как остаться брошенным; *мгновенная отмена при рождении* — «дыхание» окна на +отменах исчезает; *отмен нет вовсе* — страховочный срез 1, он в резерве. ## Классы расхождений и опоздание diff --git a/docs/architecture/orders/identity.md b/docs/architecture/orders/identity.md index b56f24a..e0696b7 100644 --- a/docs/architecture/orders/identity.md +++ b/docs/architecture/orders/identity.md @@ -24,10 +24,10 @@ Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных `ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`; отдельные прогоны одного мира дают то же соответствие; принятый мир -воспроизводит эффект склейки без случайных ложных объединений — конкретные -числа пар контрактом описи не являются. Межкогортные столкновения принимаются -по той же дисциплине, что у случайных `ClientID`: принимается конкретный -канонический мир по внешнему результату. +воспроизводит эффект склейки без случайных ложных объединений. Паспорт не +закрепляет конкретное число пар. Межкогортные столкновения принимаются по той же +дисциплине, что у случайных `ClientID`: принимается конкретный канонический мир +по внешнему результату. Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* — diff --git a/docs/architecture/orders/ingestion.md b/docs/architecture/orders/ingestion.md index ed4a0d0..52aaa21 100644 --- a/docs/architecture/orders/ingestion.md +++ b/docs/architecture/orders/ingestion.md @@ -35,10 +35,11 @@ PARTITION` могла бы заменить партицию дня неполн После завершения генератора Airflow один раз читает байтовый Kafka-чтец и записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а -дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт -конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow. +запускает его шаговый даг модельного времени, который положил слепок в топик и +ждёт конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow. `_load_ts` вычисляется при этой записи и дальше переносится без пересчёта. -Запрос забора лежит в [`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql): +Запрос забора лежит в +[`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql): `SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя. @@ -157,7 +158,7 @@ dbt snapshots — стратегия `check` вместо `timestamp`. Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде. -Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он +Позиция мира при этом не двигается, и тот же день уедет заново — в STG он окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся ([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»). @@ -191,10 +192,10 @@ dbt snapshots — стратегия `check` вместо `timestamp`. ## Что проверено Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94. -Опыты этого раздела прогнаны и подтвердили обещанное: разбиение сырья на годные -строки и брак полное и непересекающееся; две версии одного заказа легли в одну -партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от фонового -слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её +Опыты этого раздела прогнаны и подтвердили обещанное: разбиение строк STG на +годные строки и брак полное и непересекающееся; две версии одного заказа легли в +одну партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от +фонового слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это опирается, — [документ хранилища](../storage.md), «Что проверено». @@ -217,9 +218,9 @@ dbt snapshots — стратегия `check` вместо `timestamp`. Текущее состояние проверено на пересобранном мире 26 августа 2026 года при исполнении #165. После двух слепков физическая таблица содержала 3356 версий -1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним -слепком заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из -сырья. В обеих сверках расхождений нет. +1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним слепком +заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из сырых +данных. В обеих сверках расхождений нет. Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении #93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны, diff --git a/docs/architecture/orders/inventory.md b/docs/architecture/orders/inventory.md index 6362a66..4b2c5f8 100644 --- a/docs/architecture/orders/inventory.md +++ b/docs/architecture/orders/inventory.md @@ -1,20 +1,20 @@ -# Что хранит опись +# Что хранит паспорт мира Резолюции развилок [«Расхождения 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). +Термины паспорта — «растяжка-хеш» и «опора-счётчик» — заданы разделом «Чем +меряется генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и +статьёй «Паспорт мира» в [CONTEXT.md](../../../CONTEXT.md). -> Опись хранит только то, чего движение мира не меняет. -> Хеш кладём всегда, счётчик — только когда назван его читатель. +> Паспорт хранит только то, чего движение мира не меняет. Хеш кладём всегда, +> счётчик — только когда назван его читатель. - **У каждого отправленного слепка — своя строка с хешем байтов.** Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй - артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же - байты») опись начинает сторожить. + артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же байты») + паспорт начинает сторожить. - **Счётчики классов расхождений — наблюдаемых, после приоритета**, по итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных @@ -24,7 +24,7 @@ - **Контрольные числа идентичности не заводятся вовсе**: uniq известных пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них не выходит; генератор сторожит хеш, транспорт — счёт событий. -- **Опись описывает мир, а не доставку.** Работа генератора кончается на - Kafka: дошли ли байты до `ods.order` — вопрос стенда и его - проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка - приёма заказов — число заведётся вместе с ней. +- **Паспорт описывает мир, а не доставку.** Работа генератора кончается на + Kafka: дошли ли байты до `ods.order` — вопрос стенда и его проверок. Поэтому + счёта строк у слепка в паспорте нет; понадобится проверка приёма заказов — + число заведётся вместе с ней. diff --git a/docs/architecture/orders/snapshot.md b/docs/architecture/orders/snapshot.md index 4eef6a7..756893e 100644 --- a/docs/architecture/orders/snapshot.md +++ b/docs/architecture/orders/snapshot.md @@ -112,5 +112,5 @@ такого значения означает ошибку арифметики источника. Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка — -порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт -его даром, а хешу слепка в описи нужен именно названный порядок. +порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт его +даром, а хешу слепка в паспорте нужен именно названный порядок. diff --git a/docs/architecture/orders/start-world.md b/docs/architecture/orders/start-world.md index 083cf11..3cae352 100644 --- a/docs/architecture/orders/start-world.md +++ b/docs/architecture/orders/start-world.md @@ -25,11 +25,11 @@ Цена принята с открытыми глазами: у части пар стартового мира поздний назначенный заказ падает на день 7, и до первого хода мира этих пар в -`dds.identity_map` нет. Опись пар не считает -([что хранит опись](inventory.md)), поэтому красной проверки из этого не -выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на -свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем -ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* — -число верно ровно до первого хода мира; *отменить сдвиг отправки* — -`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир -перестал бы быть чистой функцией зерна. +`dds.identity_map` нет. Паспорт пар не считает ([что хранит +паспорт](inventory.md)), поэтому красной проверки из этого не выходит. +Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на свежем +стенде, в CLI заводится рычаг, стартовый мир становится особым случаем ровно +там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* — число +верно ровно до первого хода мира; *отменить сдвиг отправки* — `awaiting_order` +пропал бы навсегда; *подогнать план под горизонт* — мир перестал бы быть чистой +функцией зерна. diff --git a/docs/architecture/stand.md b/docs/architecture/stand.md index 85546cb..0948ba1 100644 --- a/docs/architecture/stand.md +++ b/docs/architecture/stand.md @@ -17,27 +17,28 @@ `airflow` и `superset`; - `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` — Airflow 3.3 с LocalExecutor, без triggerer; -- `superset` — интерфейс и подготовленное подключение ClickHouse; +- `superset` — интерфейс с заранее настроенным подключением к ClickHouse; - `prometheus` и `grafana` — сбор и просмотр метрик ClickHouse и Kafka. ## Пользователи и роли ClickHouse В ClickHouse семь пользователей и шесть ролей: -- `etl` подключает рабочие даги Airflow; роль `etl_writer` читает и пишет слои - хранилища; -- `lifecycle` применяет канонический DDL и пересоздаёт прикладные базы; роль - `lifecycle_owner` не даёт эти административные права рабочим дагам; -- `bi` подключает Superset; роль `bi_reader` читает ODS, DDS, DM и справочники - `dic`, а запросы Superset обращаются к публичным `_v`; -- `analyst` предназначен для подключения человека и читает все слои и - справочники; -- `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои, +- `etl` — учётная запись рабочих дагов Airflow; роль `etl_writer` читает и + пишет слои хранилища; +- `lifecycle` — учётная запись дагов жизненного цикла; роль `lifecycle_owner` + разрешает им применять канонический DDL и пересоздавать прикладные базы. У + рабочих дагов этих административных прав нет; +- `bi` — учётная запись Superset; роль `bi_reader` читает ODS, DDS, DM и + справочники `dic`, а запросы Superset обращаются к публичным `_v`; +- `analyst` — учётная запись аналитика с правом чтения всех слоёв и + справочников; +- `grafana` — учётная запись Grafana; роль `monitoring_reader` читает все слои, системные таблицы и данные со всех реплик кластера; -- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит - за своей подложкой; -- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри - контейнеров, но не приложения. +- `dict` — беспарольная учётная запись словаря товаров с правом чтения базы + `dic`; словарь читает через неё свою подложку; +- `default` — служебная учётная запись для проверок состояния и скриптов внутри + контейнеров; приложения с ней не подключаются. Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в [ADR 0007](../adr/0007-clickhouse-access.md). @@ -48,9 +49,8 @@ Superset получает пароль `bi`, а Grafana — пароль `grafan ## Применение настроек -Изменения значений ClickHouse в `.env` и файлов настройки серверов в -`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/` применяются -пересозданием нод: +Чтобы применить новые значения ClickHouse из `.env` или изменения файлов в +`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/`, пересоздайте ноды: ```bash docker compose up --force-recreate --wait clickhouse-01 clickhouse-02 @@ -62,9 +62,8 @@ docker compose up --force-recreate --wait clickhouse-01 clickhouse-02 ## Секреты и порты -Пароли ClickHouse и интерфейсов, пароли Postgres, ключи Airflow и Superset, -отсутствие проверки доступа у Kafka и Prometheus — намеренно простые и явно -ненастоящие настройки локального учебного стенда. Это не пример настройки +В локальном учебном стенде намеренно используются простые ненастоящие пароли и +ключи. Kafka и Prometheus работают без проверки доступа. Это не пример настройки защиты: не копируйте значения из `.env.example` в рабочую среду. Все опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не опубликован. @@ -84,20 +83,22 @@ ClickHouse: [ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication), [ON CLUSTER](https://clickhouse.com/docs/sql-reference/distributed-ddl) и [Distributed](https://clickhouse.com/docs/engines/table-engines/special/distributed). -Описание кластера задаётся через `remote_servers`, макросы — через `macros`, -подключение к keeper — через `zookeeper`; путь `ReplicatedMergeTree` содержит -`{shard}` и `{replica}`, а `Distributed` получает имя кластера, базу, локальную -таблицу и ключ шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER` -создавал отдельный путь каждого шарда без вписанных вручную значений. Для -образа зафиксирован точный текущий -[LTS-выпуск 26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts); +Кластер описан в `remote_servers`, макросы — в `macros`, а подключение к +keeper — в `zookeeper`. Путь `ReplicatedMergeTree` содержит `{shard}` и +`{replica}`. +Для `Distributed` задаются имя кластера, база, локальная таблица и ключ +шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER` создавал +отдельный путь для каждого шарда без вписанных вручную значений. В образе +закреплена точная версия текущего LTS-выпуска — +[26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts); серверы и keeper используют один образ. Настройки Kafka 4.3.1 сверены с [примером односерверного KRaft](https://github.com/apache/kafka/blob/4.3.1/docker/examples/docker-compose-files/single-node/plaintext/docker-compose.yml). -Секция метрик взята из конфигурации закреплённого образа ClickHouse и проверена -на серверах и keeper. Подготовка источников Grafana сверена с +Раздел метрик взят из конфигурации закреплённого образа ClickHouse и проверен +на серверах и keeper. Автоматическая настройка источников данных Grafana +сверена с [официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/) и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/). Prometheus собирает встроенные метрики двух серверов и keeper, а через @@ -109,18 +110,18 @@ Prometheus собирает встроенные метрики двух сер пока нет. Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor, -публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с +публичный `airflow.sdk`, API проверки состояния и SimpleAuthManager сверены с [архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html), [публичным интерфейсом](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html) и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html). Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет общий `SQLExecuteQueryOperator`, программные запросы идут через `ClickHouseHook`. Провайдер работает поверх закреплённого -`clickhouse-connect`. Официальный провайдер Kafka сам использует -`confluent-kafka`; отдельное подключение и его обёртки здесь не нужны, поэтому -клиент Kafka добавлен в образ напрямую. +`clickhouse-connect`. Официальный провайдер Kafka уже использует +`confluent-kafka`. В стенде клиент добавлен в образ напрямую, без отдельного +подключения Airflow и обёрток провайдера. -Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма -`clickhousedb://` и драйвер Postgres сверены с +Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, схема адреса +подключения `clickhousedb://` и драйвер Postgres сверены с [документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/) и [настройкой базы метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/). diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 8602d97..5dfc360 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -7,20 +7,19 @@ **Что здесь описано и чего ещё нет.** Собран этап 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`. Сессия среди них особая: в потоке такой сущности -нет, стенд режет её из событий сам. -Дальше по тексту устройство описано так, как оно проектируется; построенное от -заложенного отличает карта таблиц в конце. +`Kafka → STG → ODS` — чтец топика `hits`, таблицы STG, типизированное событие с +таблицей ошибок, поверхность актуального состояния и три матвью. Этап 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), формат @@ -97,7 +96,7 @@ ClickHouse и отклонённые варианты записаны в Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш выбран против перекоса: структурированный числовой идентификатор распределяется -по остатку от деления неравномерно. У сырья выбора нет — строку иначе не +по остатку от деления неравномерно. У сырого слоя выбора нет — строку иначе не разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один шард. @@ -157,10 +156,10 @@ FROM источник_dist`. Заводское значение `parallel_distr даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и `_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена `Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера -метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья +метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от -разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы -либо падение приёма на первом сообщении, либо тихие нули за 1970 год. +разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо +падение приёма на первом сообщении, либо тихие нули за 1970 год. Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется @@ -173,10 +172,10 @@ FROM источник_dist`. Заводское значение `parallel_distr пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR 0010](../adr/0010-order-versions-in-ods.md)). -Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition, -kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и -нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради -которого он заведён: повтор доставки в сырье обязан быть виден. +Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition, +kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у +таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство +слоя, ради которого он заведён: повтор доставки в STG обязан быть виден. Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится она один раз при записи в STG: для событий — матвью приёма, для дневного слепка @@ -192,7 +191,7 @@ kafka_offset)`: разбор полётов идёт от «какое сооб `_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью. Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание -Airflow. Переделать разобранное руками можно вставкой из сырья с фильтром по +Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по `_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по `_load_id`; дневные партиции ODS он не заменяет. @@ -223,10 +222,10 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` — исключение: источник объявляет `Array(Float64)`, а материализованное -представление ODS разбирает числа сырого JSON сразу в -`Array(Decimal(18, 2))`. DDS наследует этот тип без нового преобразования. -Прямой разбор подтвержден документацией ClickHouse через Context7 и запросом -к ClickHouse 26.3; сравнение с сырьем подтвердило: копейки всех покупок целы. +представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`. +DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден +документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение +с сырыми данными подтвердило: копейки всех покупок целы. ## Версии заказов @@ -257,16 +256,16 @@ DDS. `snapshot_date` в нём одновременно дата наблюде **Линза называется явно — в типе колонки либо в вызове функции.** Третий источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там, -где линза что-то решает — в объявлении хранимой колонки и в выражении, -считающем дату, — его не остаётся. Прописать этот пояс своей рукой — -`` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью -с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах -и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться, -поменяв его. Правило стоит на источнике пояса, а не на функции: -`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и -работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя -пишется тогда, когда нужна другая линза, чем у колонки: -`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика». +где линза что-то решает — в объявлении хранимой колонки и в выражении, считающем +дату, — его не остаётся. Прописать этот пояс своей рукой — `` в +конфигурации ноды или `TZ` контейнеру — было бы той же болезнью с другим +умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в +разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться, +поменяв его. Правило стоит на источнике пояса, а не на функции: `toDate` по +колонке, чей тип пояс несёт, законен и имени не требует — так и работают ключи +партиций `toDate(_load_ts)` у таблицы STG и у таблицы ошибок. Имя пишется тогда, +когда нужна другая линза, чем у колонки: `toDate(UTCEventTime, 'Europe/Samara')` +— это «день по часам счётчика». **Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна, @@ -314,8 +313,8 @@ UTC+4), и пересчёт идёт один раз при наполнении 0008](../adr/0008-order-ingestion.md) и [спецификация приёма заказов](orders/ingestion.md). -Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и -таблица ошибок ODS. +Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица +ошибок ODS. Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы `clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт @@ -344,15 +343,15 @@ Airflow она стоит — там это обычный `SETTINGS` у зап пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда как при фоновом первая нода продолжает принимать и копит спул для соседа. -Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё, +Платится при этом не одно ожидание на блок, а три распределённые вставки — STG, событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а компромисс полезнее показать, чем спрятать за галочкой. -Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и -запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято -осознанно — подробности в разделе «Что проверено». +Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым +значением и запись-надгробие проходят молча, не оставляя строки. Свойство +измерено и принято осознанно — подробности в разделе «Что проверено». **Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля @@ -361,14 +360,14 @@ Airflow она стоит — там это обычный `SETTINGS` у зап один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны. Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет -`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня -честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения. -Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к -сырому слою не относится. +`ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка +модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения +срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности +конвейера к сырому слою не относится. Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`). -Удвоение сырья проходит мимо неё только потому, что при повторном чтении +Удвоение строк STG проходит мимо неё только потому, что при повторном чтении `_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на отсутствии механизма. @@ -384,53 +383,54 @@ Airflow она стоит — там это обычный `SETTINGS` у зап роняет вставку и останавливает потребление до починки ([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 хранит всё, поэтому равенство «строки STG = +события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка +модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило, +красное в норме, учит не смотреть на оповещения ([ADR +0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при +исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк +STG и 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. У межслойной сверки такой опоры нет ни с -одной стороны. +против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make +check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся +разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами, +повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS +хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая +файлом в git. У межслойной сверки такой опоры нет ни с одной стороны. -## Срок жизни сырья +## Срок жизни сырых данных -Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно -отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты, -после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же -колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL -значение проставлено явно, чтобы поведение не зависело от умолчания версии. +Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это +окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел +разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню +загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками +(`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не +зависело от умолчания версии. -Нарезать сырьё по модельному дню события было бы соблазнительно — он единица -переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок -снимается целиком, только когда в нём истекли все строки, а в партиции модельного -дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста, -самая свежая строка удержит весь кусок, и данные переживут срок неограниченно. +Нарезать сырые данные по модельному дню события было бы соблазнительно — он +единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок +снимается целиком, только когда в нём истекли все строки, а в партиции +модельного дня лежит приехавшее в разное реальное время: мерж склеит куски +разного возраста, самая свежая строка удержит весь кусок, и данные переживут +срок неограниченно. В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного возраста — но не более чем на сутки, потому что партицию закрывает календарный -день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно -трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что -«уходит само» здесь обещано, а «уходит вовремя» — нет. +день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не +ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, +так что «уходит само» здесь обещано, а «уходит вовремя» — нет. -Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а -содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё -режется своими координатами: и разбор полётов, и переобработка фильтруют по -`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по +Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а +содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые +данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют +по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается. Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в @@ -440,21 +440,21 @@ D0 и к реальному календарю не привязана; паке дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы эталонные дни прямо на входе. -В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе. -Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не -ноль. +В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе. +Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно, +а не ноль. ## Таблица ошибок `ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым -текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у -брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у -строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и -сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним, -разбираться к моменту разбирательства будет уже нечем. Снятие — целыми кусками -(`ttl_only_drop_parts`), как у сырья и по той же причине: строки в партиции дня -загрузки разного возраста не более чем на сутки, и куску незачем переживать -срок из-за самой свежей строки. +текстом, метаданными доставки и классом брака. Ключи её собственные, потому что +у брака нет разобранных полей: шардируется `cityHash64` сырой строки — +`ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню +загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если +брак истекает вместе с ними, разбираться к моменту разбирательства будет уже +нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же +причине: строки в партиции дня загрузки разного возраста не более чем на сутки, +и куску незачем переживать срок из-за самой свежей строки. Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине @@ -500,13 +500,12 @@ kafka_offset)`: смотрят такую таблицу от класса, а ## Раскладка SQL -Исполняемые дагами запросы лежат по правилу -`sql/<слой>/<объект>_<роль>.sql`. Слой — слой цели запроса: забор заказов в -сырьё живёт в `sql/stg/orders_raw_load.sql`, разбор — в -`sql/ods/order_parse.sql`. Даг называет файлы, передаёт параметры и задаёт -порядок выполнения, но не хранит текст преобразований. Два выражения разбора -делят локальный макрос Jinja в том же файле; подключаемого SQL-фрагмента между -ними нет. +Исполняемые дагами запросы лежат по правилу `sql/<слой>/<объект>_<роль>.sql`. +Слой — слой цели запроса: забор заказов в STG живёт в +`sql/stg/orders_raw_load.sql`, разбор — в `sql/ods/order_parse.sql`. Даг +называет файлы, передаёт параметры и задаёт порядок выполнения, но не хранит +текст преобразований. Два выражения разбора делят локальный макрос Jinja в том +же файле; подключаемого SQL-фрагмента между ними нет. SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода обработчик DAG читает список DDL-файлов, загружает содержимое каждого @@ -524,10 +523,10 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр |---|---| | `00-databases.sql` | базы слоёв и зона справочников | | `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё | -| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников | +| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы STG обоих источников | | `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок | | `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS | -| `40-stg-views.sql` | матвью приёма: чтец в сырьё | +| `40-stg-views.sql` | матвью приёма: чтец в STG | | `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций | | `50-dds-views.sql` | публичные договоры сущностей DDS | | `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций | @@ -537,14 +536,14 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр правила порядка слоёв к ней не применяются. Порядок остальных задают два правила. Первое: матвью принадлежит слою своей -цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому -что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся -матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает -читать топик, когда к нему привязывают первую матвью; создай её раньше -разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS -никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден, -поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse -снесены, а данные Kafka целы, — то есть на обычной отладке. +цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что +наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся матвью +приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает читать +топик, когда к нему привязывают первую матвью; создай её раньше разбора — и всё, +что доедет в зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в +ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. +Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на +обычной отладке. Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью `lifecycle`: один файл — одна последовательная задача @@ -579,13 +578,13 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр |---|---|---| | `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` | | `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки | -| `stg` | `stg.hits_raw_mv` | наполняет сырьё из чтеца | +| `stg` | `stg.hits_raw_mv` | наполняет STG из чтеца | | `stg` | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью | | `stg` | `stg.orders_raw_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.event_mv`, `ods.event_errors_mv` | разбор STG в событие и в ошибки | | `ods` | `ods.order_rep` / `_dist` | типизированные версии заказа | | `ods` | `ods.order_v` | текущая версия заказа на языке источника | | `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | @@ -612,9 +611,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр | `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором | | `dic` | `dic.products` | словарь товаров поверх подложки | -Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два -`INSERT SELECT` одной задачи `parse_batch` из файла -`sql/ods/order_parse.sql`. +Матвью разбора у заказов нет: срез STG раскладывают по этим двум целям два +`INSERT SELECT` одной задачи `parse_batch` из файла `sql/ods/order_parse.sql`. В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции; @@ -655,11 +653,11 @@ DDL-словарь с файловым источником внутри `user_f **Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и `system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT` -на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. -На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов -лежали на обоих шардах, после явного нуля и повторного разбора того же -`_load_id` — ни одного. Вершина версий уцелела: деловые поля `ods.order_v` -сошлись с эталоном, посчитанным прямо из сырья. +на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. На +живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов лежали на +обоих шардах, после явного нуля и повторного разбора того же `_load_id` — ни +одного. Вершина версий уцелела: деловые поля `ods.order_v` сошлись с эталоном, +посчитанным прямо из STG. **Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил, что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов @@ -683,19 +681,19 @@ DDL-словарь с файловым источником внутри `user_f потолком разбор отдаёт как `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` не изменил, а таблица ошибок -записала тот же брак второй раз, как и обещано спецификацией приёма. +Про сам приём заказов снято следующее. Предикат формы провода разложил все 13 +661 строку STG, приехавшую при исполнении #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` считает прочитанное с диска, а не подошедшее, — @@ -758,27 +756,26 @@ DDL-словарь с файловым источником внутри `user_f вовсе, до 7 августа утверждение держалось на опыте владельца. - Упавшая матвью роняет вставку и останавливает потребление до починки. Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за - двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, а - офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение - доехало само, без повторной отправки, отставание ушло в ноль, событие - разобралось. Сносить надо именно распределённую таблицу: вставка в - `Distributed` кладёт блок в спул и сразу возвращает управление, так что на - сносе локальной ошибка всплыла бы фоном и утверждение показалось бы - опровергнутым. -- `_load_ts` в `ods.event` — это метка исходной строки сырья, а не время + двадцать секунд (сброс блока идёт за 7,5) в STG не приехало ничего, а офсет + группы застыл с отставанием в одно сообщение. Цель вернули — сообщение доехало + само, без повторной отправки, отставание ушло в ноль, событие разобралось. + Сносить надо именно распределённую таблицу: вставка в `Distributed` кладёт + блок в спул и сразу возвращает управление, так что на сносе локальной ошибка + всплыла бы фоном и утверждение показалось бы опровергнутым. +- `_load_ts` в `ods.event` — это метка исходной строки STG, а не время разбора. Сверено по `WatchID` на двух тысячах событий модельного дня, залитого дважды: у каждого события метка совпала с меткой одной из двух его доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди доставок» ноль, то есть `now64()` в матвью разбора нет. - Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново - созданные матвью разбора при живом чтеце: сообщение, отправленное сразу - после, легло в сырьё и не попало в ODS никуда — ни в событие, ни в ошибки; - то же сообщение через минуту разобралось штатно. Воспроизведено дважды - 7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация - файлов DDL, только приходит он с другой стороны — не при первом создании, а - при замене матвью на работающем стенде. Практический вывод один: правишь - матвью — не верь ближайшей отправке, повтори её. Чем именно держится - задержка, не измерено; наблюдение записано как наблюдение. + созданные матвью разбора при живом чтеце: сообщение, отправленное сразу после, + легло в STG и не попало в ODS никуда — ни в событие, ни в ошибки; то же + сообщение через минуту разобралось штатно. Воспроизведено дважды 7 августа + 2026 года. Это тот же зазор, о котором предупреждает нумерация файлов DDL, + только приходит он с другой стороны — не при первом создании, а при замене + матвью на работающем стенде. Практический вывод один: правишь матвью — не верь + ближайшей отправке, повтори её. Чем именно держится задержка, не измерено; + наблюдение записано как наблюдение. - Форма ключа `ods.event` принимается такой, как её задумала спека: выражение `intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а `SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11 @@ -811,18 +808,19 @@ DDL-словарь с файловым источником внутри `user_f Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`) -читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в -таблицу ошибок; приём при этом не останавливается. Проверено 5 августа -2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — -в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при -чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на -том же наборе даёт ровно те же две строки и тот же офсет. +читаются, двигают офсет потребителя и не дают строки вовсе — ни в STG, ни в +таблицу ошибок; приём при этом не останавливается. Проверено 5 августа 2026 +года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — в STG +приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при чём: +`LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на том +же наборе даёт ровно те же две строки и тот же офсет. Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за случая, которого стенд не производит, — размен не в ту сторону. Знать о нём стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в -самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он. +самой таблице STG, `kafka_offset` лежит там колонкой, и дырка в офсетах — это +он. Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS <локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно @@ -865,7 +863,7 @@ Kafka с пустым значением (ноль байт) и запись-н - **`JSONExtract` читает `Decimal` и из числа JSON.** Массив `purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на - ClickHouse 26.3; все суммы после ODS совпали с сырьем до копейки. + ClickHouse 26.3; все суммы после ODS совпали с сырыми данными до копейки. **Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие матвью разбора, закрыты опытами при исполнении #43 и переехали выше. diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md index d5a3ebe..3364601 100644 --- a/docs/architecture/testing.md +++ b/docs/architecture/testing.md @@ -23,9 +23,9 @@ заметит потерянного подключения Superset: он про ClickHouse и только. Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики -против описи — вопрос к ClickHouse: строки в `ods.event` считает сам -сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене -они подошли бы смоуку. +против паспорта — вопрос к ClickHouse: строки в `ods.event` считает сам сервер и +отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене они +подошли бы смоуку. ## Карта целей @@ -44,11 +44,11 @@ | `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-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 test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [паспорт мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с | **Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за разными дверями охватывают разное: корневая не видит ни одного файла @@ -87,7 +87,7 @@ Airflow. В цену целей она не входит. На готовом м `make rebuild-storage` тоже не проверка. Команда через Airflow удаляет прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL, -заливает стартовый мир и возвращает его пульт к концу стартовых дней. Службы и +заливает стартовый мир и возвращает его позицию к концу стартовых дней. Службы и их тома она сохраняет, поэтому возврат к каноническим данным не требует холодного запуска всего стенда. @@ -131,10 +131,9 @@ ClickHouse отвечает сразу. дорогая. Правки дагов и Superset — корневой `make lint`. Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней -для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы -узнать, тот ли получается мир, его надо получить. Место выбрано по той же -оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не -нужен, — и +для сверки с паспортом мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы +узнать, тот ли получается мир, его надо получить. Место выбрано по той же оси +«кого спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и краснеет проверка там, где надо: сразу после правки генератора. Не будь её, расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит поломкой хранилища, а не забытой пересборкой. @@ -172,10 +171,10 @@ ClickHouse отвечает сразу. жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он становится темой урока. -Довод не про цену прогона, а про данные. Вложенное остаётся: у сырья срок +Довод не про цену прогона, а про данные. Вложенное остаётся: у сырых данных срок жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в -лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и -их. Хуже явного мусора: явный видно. +лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и их. +Хуже явного мусора: явный видно. Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.** Дешевле всего это выходит партицией — опыт играет модельный день за границей @@ -184,10 +183,10 @@ ClickHouse отвечает сразу. же механизм, на котором стоит переобработка дня X. Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех, -что стенд произвёл сам: подневный счёт событий против описи мира ловит и +что стенд произвёл сам: подневный счёт событий против паспорта мира ловит и сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге, -ничего при этом не вкладывая. Это единственный постоянный сторож цепочки -Kafka → STG → ODS, и живёт он в `make check-clickhouse`. +ничего при этом не вкладывая. Это единственный постоянный сторож цепочки Kafka → +STG → ODS, и живёт он в `make check-clickhouse`. Правило, которое такая проверка обязана выдержать и которое стоит держать в голове для следующей: **на растущих данных утверждать можно только про @@ -281,9 +280,9 @@ Kafka по той же причине спрашивают снаружи. Её `make check-clickhouse` запускает отдельную глубокую проверку ClickHouse: описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`, -`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая -из десяти проверок спрашивает словарь товаров на обеих нодах. Последняя — -подневный счёт событий против описи, о нем сказано выше. +`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая из +десяти проверок спрашивает словарь товаров на обеих нодах. Последняя — подневный +счёт событий против паспорта, о нем сказано выше. ## Что проверено @@ -352,11 +351,11 @@ 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. Эта правка добавила один тест — проигрыш дня. +Паспорт пересобрался за 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 секунды. Предел @@ -464,11 +463,11 @@ shell-версия узкой пересборки за 2 м 25 с пересо `make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за 3 м 35 с. Отправка семи слепков заняла около 28 секунд. -Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта -мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время -ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й -секунде, обработчик DAG стал здоров примерно на 458-й. Числа здесь называют -цену нынешнего стенда, а не выносят вердикт о регрессии. +Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и дагов +модельного времени. Весь рост приписывать слепкам нельзя: на холодном прогоне +основное время ушло ещё до сценариев ожидания: `airflow-init` завершился +примерно на 320-й секунде, обработчик DAG стал здоров примерно на 458-й. Числа +здесь называют цену нынешнего стенда, а не выносят вердикт о регрессии. **Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за @@ -484,25 +483,25 @@ shell-версия узкой пересборки за 2 м 25 с пересо от файла, и ближний выигрывает. **Перезамер 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 — тесты добавляли +(`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`. +адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца топика». +Затем положили в STG заведомо негодную строку — брак появился, и диагноз +сменился на второй, с признаком для различения: «сойдётся недостача с числом +брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки опыта +убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт вернулся к +401 185, и это заодно показало дедупликацию: повтор дня не удвоил счёт под +`FINAL`. Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону: diff --git a/docs/architecture/world.md b/docs/architecture/world.md index 63a9356..103e9a5 100644 --- a/docs/architecture/world.md +++ b/docs/architecture/world.md @@ -1,12 +1,13 @@ -# Мир и его пульт +# Мир и модельное время -Модельное время стенда со стороны оператора: как рождается мир, что двигает -его ось, кто играет дни и где хранится позиция. -Доводы об устройстве пульта — в [ADR 0009](../adr/0009-world-control.md), о -безопасном повторном подъеме и жизненном цикле мира — в -[ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день стартового -мира неполон и что хранит опись, разобрано в [справочнике стартового -мира](orders/start-world.md) и [справочнике описи](orders/inventory.md). +Оператор управляет модельным временем стенда. Здесь разобрано, как рождается +мир, что двигает его ось, какие даги играют дни и где хранится позиция. Доводы +об устройстве дагов модельного времени — в [ADR +0009](../adr/0009-world-control.md), о безопасном повторном подъёме и жизненном +цикле мира — в [ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день +стартового мира неполон и что хранит паспорт, разобрано в [справочнике +стартового мира](orders/start-world.md) и [справочнике +паспорта](orders/inventory.md). ## Стартовый мир @@ -15,7 +16,7 @@ генератора и играет в `hits` первые восемь дней модельного времени — понедельник по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в `orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только -тогда ставит позицию мира в `8`. Так у всякой лабы есть данные, и всегда одни и +тогда ставит позицию мира в `8`. Так у каждой лабы есть данные, и всегда одни и те же. Последний день намеренно неполон: события дня 7 уже приехали, а его слепок @@ -28,21 +29,21 @@ образ и не заливает данные повторно. Изменённую схему применяют адресно либо явной командой `make rebuild-storage`. -## Опись мира +## Паспорт мира -Сам мир в git не хранится — он чистая функция зерна, и держать его в -репозитории значило бы держать там кэш. Вместо него лежит **опись мира**, +Сам мир в git не хранится: он вычисляется из зерна, поэтому готовая копия была +бы лишь кэшем. Вместо него лежит **паспорт мира**, [`data/world-inventory.json`](../../data/world-inventory.json): зерно, версия генератора, хеш каталога товаров и по строке на каждый день — дата, число -событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики -классов расхождений по заказам. Опись отвечает на единственный вопрос: тот ли -это мир, что был вчера. +событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики классов +расхождений по заказам. Паспорт отвечает на единственный вопрос: тот ли это мир, +что был вчера. -Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что -собирается из кода сегодня: правка генератора меняет мир, и опись надо -пересобрать — `make inventory` там же. `make check-clickhouse` в корне сверяет -с описью то, что доехало до `ods.event`, подневно. Хеш дня можно пересчитать и -руками — это обычный `sha256sum` файла, который пишет файловый приёмник: +Спрашивают его двое. `make test` в `generator/` сверяет паспорт с тем, что +собирается из кода сегодня: правка генератора меняет мир, и паспорт надо +пересобрать — `make inventory` там же. `make check-clickhouse` в корне по дням +сверяет паспорт с данными, поступившими в `ods.event`. Хеш дня можно пересчитать +и руками — это обычный `sha256sum` файла, который пишет файловый приёмник: ```bash uv run --project generator python -m clickstream_generator batch \ @@ -52,18 +53,19 @@ sha256sum tmp/day0.jsonl ## Как запустить генератор -События производит генератор из [`generator/`](../../generator/). Модельный день — -функция зерна и номера дня, поэтому один и тот же день всегда даёт те же -события: обрыв лечится повтором. Позицию на оси генератор не помнит — какой -день играть, решает зовущий. +События производит генератор из [`generator/`](../../generator/). Модельный день +— функция зерна и номера дня, поэтому один и тот же день всегда даёт те же +события: обрыв лечится повтором. Позицию на оси генератор не помнит — номер дня +передаёт вызывающая команда. -Режима два, и различаются они темпом. **Пакетный** гонит день подряд, без пауз: -так заливается мир и так переигрывается день после обрыва. **Живой** держит темп -модельного времени — по умолчанию ×60: модельные сутки за 24 реальные минуты, -суточная волна разворачивается на глазах, отставание видно в логе. +Режима два, и различаются они темпом. В **пакетном** режиме события дня +отправляются подряд, без пауз: так заливается мир и так переигрывается день +после обрыва. **Живой** держит темп модельного времени — по умолчанию ×60: +модельные сутки за 24 реальные минуты, суточная волна разворачивается на глазах, +отставание видно в журнале. -На стенде генератор ходит своим образом — разовой службой, которую поднимают -и убирают на один прогон. На каждый режим по цели: +На стенде генератор запускается из отдельного образа как разовая служба. После +прогона её убирают. Для каждого режима есть своя цель: ```bash # день D0 целиком в топик hits @@ -80,24 +82,24 @@ make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000 | `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком | | `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе | -Куда отправлять, обеим целям задаёт сам стенд: внутри сети Compose это брокер +Адрес назначения для обеих целей задаёт стенд: внутри сети Compose это брокер `kafka:9092` и топик `hits`. -Образ цели не пересобирают: нет образа — Compose соберёт его сам, есть — -возьмёт как есть. Пересобрать намеренно, после правки `generator/Dockerfile` -или зависимостей, — отдельной командой: +При обычном запуске образ генератора не пересобирается. Если образа нет, Compose +соберёт его; если есть, использует готовый. После правки `generator/Dockerfile` +или зависимостей образ пересобирают отдельной командой: ```bash docker compose --profile generator build generator ``` -Тот же образ несёт заливка стартового мира. Перед такой заливкой -`world_initialize` сам собирает его из текущего дерева; на готовом мире -повторный `make up` до сборки не доходит. +Для заливки стартового мира нужен тот же образ. Перед такой заливкой +`world_initialize` собирает его из текущего дерева; на готовом мире повторный +`make up` до сборки не доходит. -Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и -цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ -устарел, видно по собственному прогону — это обратная связь, а не ловушка. +Сборка образа и запуск контейнера разделены намеренно. Если цель запуска сама +пересобирает образ, различие между этими действиями исчезает. Следующий запуск +покажет, что образ устарел. Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня — @@ -108,21 +110,21 @@ uv run --project generator python -m clickstream_generator batch \ --day 0 --limit 100 --file tmp/day0.jsonl ``` -Несколько дней подряд играет один запуск: `--days 8`. Всё, что принимается -флагами, принимается и переменными окружения — так генератор позовёт даг -этапа 5; полный список у `--help`. +За один запуск можно проиграть несколько дней подряд: `--days 8`. Всё, что +принимается флагами, принимается и переменными окружения — так генератор позовёт +даг этапа 5; полный список у `--help`. **Значения по умолчанию есть не у всех параметров, и это сделано специально.** Зерно, число дней и темп живого дня описывают модель мира — их генератор знает -сам. День на оси, приемник и имя топика описывают стенд, на котором генератор -запустили: если их не задать, он откажется работать и вернет ненулевой код еще -до первого события. Поэтому значение дня по умолчанию живет в `Makefile`: день -задает тот, кто запускает, а не тот, кого запускают. +сам. День на оси, приёмник и имя топика относятся к конкретному запуску +генератора на стенде: если их не задать, он откажется работать и вернёт +ненулевой код ещё до первого события. Поэтому значение дня по умолчанию живёт в +`Makefile`: день задаёт тот, кто запускает, а не тот, кого запускают. -## Пульт мира +## Даги модельного времени -Считать дни самому необязательно. В Airflow живёт **пульт мира**: два дага -создают его начальное состояние, а три двигают ось дальше. +Считать дни самому необязательно. В Airflow живут **даги модельного времени**: +два дага создают начальное состояние мира, а три двигают ось дальше. | Даг | Что делает | | --- | --- | @@ -130,33 +132,33 @@ uv run --project generator python -m clickstream_generator batch \ | `world_recreate` | удаляет только прикладной мир и запускает его создание заново | | `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один | | `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут | -| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём | +| `world_live` | выключатель: пока снят с паузы, запускает `world_live_day` день за днём | -Работник не только играет день: следом он отправляет слепок заказов за -сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший -слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины -стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на -месте ([ADR 0008](../adr/0008-order-ingestion.md)). +Сыграв день, шаговый даг отправляет слепок заказов и запускает даг +`orders_ingest`. Тот забирает слепок из топика `orders` в `stg`; шаговый даг +ждёт его завершения. Так две половины стенда идут в ногу: упал приём — падает и +шаговый даг, а позиция остаётся на месте ([ADR +0008](../adr/0008-order-ingestion.md)). Позицию хранит переменная Airflow `world_position` — номер первого несыгранного -дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон -позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая -переменная означает, что мир ещё не создавали; `-1` — очистка не закончена, -`0` — очистка закончена, а начальная генерация ещё не завершена. Работники -начинают играть только с позиции `8` и больше. +дня. Шаговый даг записывает позицию только после успешного завершения дня: +оборванный прогон позицию не двигает, и следующий запуск играет тот же день +заново. Отсутствующая переменная означает, что мир ещё не создавали; `-1` — +очистка не закончена, `0` — очистка закончена, а начальная генерация ещё не +завершена. Шаговые даги начинают играть только с позиции `8` и больше. -В форме запуска можно выбрать только число дней. Первый день работник берет из -`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options». -Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую -позицию работник пишет в журнал шага `remember_played`. +В форме запуска можно выбрать только число дней. Первый день шаговый даг берёт +из `world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced +Options». Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и +новую позицию шаговый даг пишет в журнал шага `remember_played`. Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно — -встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы -целиком — [ADR 0009](../adr/0009-world-control.md). +встанет на границе модельных суток, доиграв начатый день. Форма дагов времени и +доводы целиком — [ADR 0009](../adr/0009-world-control.md). -**У этого решения есть цена: планировщику Airflow отдан сокет Docker** — -иначе ему не поднять контейнер генератора. Доступ к сокету равен правам root -на машине; для локального учебного стенда это приемлемо, но знать об этом -нужно. Доступ дает не само монтирование сокета, а членство в группе `docker`: -у нее на каждой машине свой GID, он записан в `.env`, а узнать его можно -командой `getent group docker`. +**У этого решения есть цена: планировщик Airflow подключён к сокету Docker.** +Без него планировщик не сможет запустить контейнер генератора. Доступ к сокету +равен правам root на машине; для локального учебного стенда это приемлемо, но +знать об этом нужно. Доступ даёт не само монтирование сокета, а членство в +группе `docker`: у неё на каждой машине свой GID. Он записан в `.env`, а узнать +его можно командой `getent group docker`. diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 630c83c..03103be 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -22,12 +22,12 @@ и покажет, за что отвечает каждый слой. Пока главы нет, смотрите [справочник хранилища](../architecture/storage.md). -### Потоки Airflow и пульт мира +### Потоки Airflow и даги модельного времени -Пульт играет модельный день, а дневной конвейер раскладывает данные по слоям. -Глава поможет увидеть их связь и роль каждого дага. Пока главы нет, смотрите -[справочник конвейера](../architecture/etl.md) и -[справочник мира и его пульта](../architecture/world.md). +Шаговый даг играет модельный день, а дневной конвейер раскладывает данные по +слоям. Глава поможет увидеть их связь и роль каждого дага. Пока главы нет, +смотрите [справочник конвейера](../architecture/etl.md) и [справочник мира и +дагов времени](../architecture/world.md). ### Мониторинг: куда смотреть diff --git a/docs/labs/dq-errors-daily.md b/docs/labs/dq-errors-daily.md index 2786b9f..48bb5e0 100644 --- a/docs/labs/dq-errors-daily.md +++ b/docs/labs/dq-errors-daily.md @@ -62,9 +62,9 @@ ## Ритм пересборки: витрина переживает источник -Таблицы брака живут месяц (`TTL`), сырье — трое суток. Витрина живет без -срока: сводка «сколько брака было в марте» нужна и после того, как сами -строки марта удалены. +Таблицы брака живут месяц (`TTL`), сырые данные — трое суток. Витрина живет без +срока: сводка «сколько брака было в марте» нужна и после того, как сами строки +марта удалены. Отсюда правило прогона: пересобираются **только те дни загрузки, которые еще есть в источнике**. Спроси у источника список живых дней и замени diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 036c1c6..052065a 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -272,12 +272,13 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate Классы пересекаются — приоритет: `cancelled` > `lost_event` > `duplicate_event` > `amount_delta` > `match`. -Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов -впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), -ориентир ~10%. Точные доли зафиксированы замером на каноническом мире -(28 дней, 2026-08-30, #194) — запись в «Что проверено» -[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и -только по дням с закрытым окном (раздел 8). +Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов впервые +появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), ориентир +~10%. Точные доли зафиксированы замером на каноническом мире (28 дней, +2026-08-30, #194) — запись в «Что проверено» [карты +проверок](../architecture/testing.md); паспорт хранит счётчики наблюдаемых +классов после приоритета — в заказах и только по дням с закрытым окном (раздел +8). Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) — механически дублирует B. @@ -311,14 +312,13 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate запуск пишет полный текущий набор различных пар; предыдущий результат не служит источником следующего. - **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») — - параметр мира; значение фиксирует эта спека: 15%, детерминировано - от seed. Ядро лабы: - `uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам. - Константа мира: каждый двухкуковый покупатель делает минимум по одному - заказу с каждой куки — иначе вторая кука не попадает в карту соответствий - (она строится только из покупок) и лаба не воспроизводится. Число таких - пар растёт, пока мир едет, и в опись не кладётся - ([что хранит опись](../architecture/orders/inventory.md)). + параметр мира; значение фиксирует эта спека: 15%, детерминировано от seed. + Ядро лабы: `uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам. + Константа мира: каждый двухкуковый покупатель делает минимум по одному заказу + с каждой куки — иначе вторая кука не попадает в карту соответствий (она + строится только из покупок) и лаба не воспроизводится. Число таких пар растёт, + пока мир едет, и в паспорт не кладётся ([что хранит + паспорт](../architecture/orders/inventory.md)). - Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и **«известные пользователи»** (после склейки) — оба числа рядом в дашборде. @@ -339,16 +339,14 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или VIEW поверх локальной таблицы проявляются в дашборде сами. - **Приём 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(сырой строки)`; полный - список и доводы — в [доке хранилища](../architecture/storage.md). Урок - «какая нода читала топик — меняется между прогонами, куда легли данные — - нет» живёт на событиях: при пакетном заборе читает та нода, которую - спросили. + consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели. У + заказов приём другого рода — пакетный забор без MV, чтец на одной ноде, топик + `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)). + Раскладку в обоих случаях решает ключ: события — по `cityHash64(ClientID)` + (см. 1.3), заказы — `cityHash64(order_id)`, STG — `cityHash64(сырой строки)`; + полный список и доводы — в [доке хранилища](../architecture/storage.md). Урок + «какая нода читала топик — меняется между прогонами, куда легли данные — нет» + живёт на событиях: при пакетном заборе читает та нода, которую спросили. - **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`, `EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей сообщения сверяется с контрактным; строка с NULL среди опорных колонок @@ -444,23 +442,24 @@ README. | DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже | У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции -суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья — -см. [доку хранилища](../architecture/storage.md). -Потребительские запросы и витрины читают сущности DDS через `_v` с явным -списком колонок; `_rep` и `_dist` служат загрузке, диагностике и обслуживанию. -Договор держится конвенцией, а не правами: модель доступа описана в -[ADR 0007](../adr/0007-clickhouse-access.md), договор чтения — в -[доке хранилища](../architecture/storage.md), «Имена объектов». +суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырых данных +— см. [доку хранилища](../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)). Слой -сырья остаётся точкой повтора и разбора одной принятой порции. +0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у +событий, но матвью к нему не привязана, и порцию в STG забирает шаг под +управлением 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`, но версию @@ -531,31 +530,28 @@ Kafka день переигрывается генератором заново: пачками), Postgres остаётся только служебной базой Airflow. Смешанность ландшафта выражена режимами и частотами, а не второй трубой. -## 8. Эталонный мир и опись +## 8. Эталонный мир и паспорт -В git хранится только опись эталонного мира; сам снимок генерируется на -месте — при `make up` и при проверках (решение развилки «Производительность» -карты #26, подробности — [спека генератора](2026-08-01-generator.md), -раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней -снято на этапе 7. Опись несёт -паспорт мира (каноническое зерно, версия генератора), контрольные счётчики -и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — -сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман -карты #10) закрыта этим же ходом: версионируется опись. -Контрольные числа описи: +В git хранится только паспорт эталонного мира; сам снимок генерируется на месте +— при `make up` и при проверках (решение развилки «Производительность» карты +#26, подробности — [спека генератора](2026-08-01-generator.md), раздел 5). +Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней снято на этапе +7. Паспорт несёт шапку с каноническим зерном и версией генератора, контрольные +счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — +сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман карты +#10) закрыта этим же ходом: версионируется паспорт. Контрольные числа паспорта: - по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание - «слепок переснимается и даёт те же байты» опись сторожит наравне с днями; + «слепок переснимается и даёт те же байты» паспорт сторожит наравне с днями; - заказная сторона: счётчики наблюдаемых классов расхождений после приоритета; каждый показывает число заказов и хранится только для дня с - закрытым окном (читатель — техническая проверка `classes_vs_inventory` - звена DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для - общего числа заказов и выручки в описи пока нет: для них не назван - читатель; + закрытым окном (читатель — техническая проверка `classes_vs_inventory` звена + DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для общего числа + заказов и выручки в паспорте пока нет: для них не назван читатель; - контрольные числа идентичности не заводятся: uniq известных пользователей и число двухкуковых покупателей растут, пока мир едет, а счётчик без - названного читателя в опись не кладётся - ([что хранит опись](../architecture/orders/inventory.md)). + названного читателя в паспорт не кладётся ([что хранит + паспорт](../architecture/orders/inventory.md)). Размер снимка замерен на этапе 7 — запись в разделе «Что проверено» [карты проверок](../architecture/testing.md). @@ -572,7 +568,7 @@ v2 стартует пустым, поэтому объём ниже — это | 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 | @@ -619,8 +615,8 @@ v2 стартует пустым, поэтому объём ниже — это 4. Трансформации и витрины: модель DDS заказа, сессии, identity_map, выручка, сверка A+C. 5. Airflow: `etl_pipeline` (партиционная переобработка). -6. Расхождения B+D и опоздания; счётчики описи. -7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация +6. Расхождения B+D и опоздания; счётчики паспорта. +7. Эталонный мир: паспорт и пересборка снимка, чек-скрипты. CI-генерация снята (спека генератора, раздел 5). 8. Superset-дашборд v2. 9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов @@ -658,15 +654,15 @@ ADR 0002 с приёмочными совпадают почти целиком. расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом. - Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не - переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг - самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и - конвейер, в который она встает, проектируются вместе. Сюда же — - необязательная лаба «свой дашборд в Superset» (#197): она стоит на - витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как - остальные лабы пока на уровне идей. Единый стиль блока - домашек — за работой учебного пути. Спека даёт будущим лабам только - опорные точки — контрольные числа описи (сверка, идентичность). Явное - следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет. + переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг самопроверки + забраны в этап 4 резолюцией #135, потому что витрина менти и конвейер, в + который она встает, проектируются вместе. Сюда же — необязательная лаба «свой + дашборд в Superset» (#197): она стоит на витринах домашек DM и продумана до + готового ТЗ в этапе 8, тогда как остальные лабы пока на уровне идей. Единый + стиль блока домашек — за работой учебного пути. Спека даёт будущим лабам + только опорные точки — контрольные числа паспорта (сверка, идентичность). + Явное следствие: после этапа 9 стенд работает, но учебного пути на нём ещё + нет. - Инкрементальный ETL (#8) — свой issue. - Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд. - TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index bb60b67..9e45423 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -41,10 +41,10 @@ конструкция внутри оправдана только наблюдаемым эффектом: без него это сложность, за которую никто не заплатил. - **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств - мира сдвигает почти все числа разом — генератор случайных чисел выдаёт - другую последовательность. Опираться на конкретные числа поэтому нечем. - Хеши описи обещают не постоянство чисел, а то, что их сдвиг не пройдёт - молча: пересобрал опись — увидел diff. + мира сдвигает почти все числа разом — генератор случайных чисел выдаёт другую + последовательность. Опираться на конкретные числа поэтому нечем. Хеши паспорта + обещают не постоянство чисел, а то, что их сдвиг не пройдёт молча: пересобрал + паспорт — увидел diff. - **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он правкой генератора после взгляда на получившиеся распределения. Оборона, которая дороже ошибки, не заводится. @@ -59,15 +59,15 @@ модельный день D — функция (зерно, D). Между прогонами живут только зерно и позиция на оси времени. - **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка — - хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания. + хешами паспорта. Транспорт (офсеты Kafka, темп) — вне обещания. - **Схема — контракт генератора.** Python-модуль с чистыми данными; хранилище строится по рендеренной документации, границу сторожит строгий приём на стороне хранилища. - **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические байты; приёмники — файл, Kafka пачкой, Kafka с темпом. - **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со - стартовым миром — 8 дней; - в git — только опись мира; автоматических порогов по времени нет. + стартовым миром — 8 дней; в git — только паспорт мира; автоматических порогов + по времени нет. ## 1. Модель мира @@ -109,23 +109,21 @@ начинается в D0) — только дают, кому возвращаться в первые дни: дневная аудитория на полке с самого D0, разгона «пустого магазина» нет. - **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица - здесь — человек, не кука: план помечает часть людей покупателями - (доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают - вторую куку. Обе куки пары принадлежат одному человеку одной когорты; - вторая рождается в пределах его окна активности, без фиксированного - зазора — по тому же затухающему профилю, что и возвраты (уточнение при - исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит. - Равные шансы по всему окну означали бы вторую куку у давно ушедшего - человека — и вчетверо меньше пар, реализованных внутри снимка. - Каждой паре план назначает дни гарантированных заказов: по - одному на куку, из дней активности этой куки, на оси от D0 и позже; - человеку предыстории, чьё окно активности таких дней не оставляет, - пара не назначается. День-функция обязана назначенные заказы - реализовать; остальные покупки — вольные, их решает генератор торговых - событий (#40). План считает пары, реализованные в запрошенном горизонте; - опись это число не хранит — оно растёт вместе с миром. Условность в данных - не видна: дни назначены той же случайностью, просто брошенной планом один - раз. + здесь — человек, не кука: план помечает часть людей покупателями (доля — + раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают вторую куку. + Обе куки пары принадлежат одному человеку одной когорты; вторая рождается в + пределах его окна активности, без фиксированного зазора — по тому же + затухающему профилю, что и возвраты (уточнение при исполнении #38, + 2026-08-02): куку человек заводит, пока ещё ходит. Равные шансы по всему окну + означали бы вторую куку у давно ушедшего человека — и вчетверо меньше пар, + реализованных внутри снимка. Каждой паре план назначает дни гарантированных + заказов: по одному на куку, из дней активности этой куки, на оси от D0 и + позже; человеку предыстории, чьё окно активности таких дней не оставляет, пара + не назначается. День-функция обязана назначенные заказы реализовать; остальные + покупки — вольные, их решает генератор торговых событий (#40). План считает + пары, реализованные в запрошенном горизонте; паспорт это число не хранит — оно + растёт вместе с миром. Условность в данных не видна: дни назначены той же + случайностью, просто брошенной планом один раз. - **Своя ось модельного времени.** Ось событий начинается в фиксированный день D0 (понедельник — см. раздел 5); реальный календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси @@ -133,10 +131,10 @@ зависящая (значение — раздел 9). Между прогонами живут только зерно и позиция на оси: выключенный ноутбук — мир замер, потом продолжил. - **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт - с эталонного снимка, дальше «прожить следующий день» — явное действие. - Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг - «дышат»; включается по требованию — постоянным фоном идёт, только пока - включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)). + с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой + день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»; + включается по требованию — постоянным фоном идёт, только пока включён + выключатель `world_live` ([ADR 0009](../adr/0009-world-control.md)). - **Граница суток — единственный структурный шов.** Сессии режутся по ней, дневная партиция самодостаточна; в конце модельного дня — слепок заказов. День проживается целиком, полдня не бывает: недожитый из-за обрыва день @@ -148,14 +146,13 @@ Отклонено с доводами: - *Мутирующее состояние мира* («мир стареет»): ломает параллельность по - дням, требует чекпоинтов, счётчики описи узнаваемы только постфактум; - ни один урок стенда на старении не стоит. + дням, требует чекпоинтов, счётчики паспорта узнаваемы только постфактум; ни + один урок стенда на старении не стоит. - *Чистая функция без слоя состава*: глобальные инварианты пришлось бы выводить в каждом дне заново — тот же план мира, но неявный и размазанный. - *Привязка модельного времени к реальному календарю* (T-1 с догоном): - конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает - даты эталонного мира зависимыми от даты запуска, опись теряет - воспроизводимость. + конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты + эталонного мира зависимыми от даты запуска, паспорт теряет воспроизводимость. Отклонено при исполнении #38 (2026-08-02): @@ -170,9 +167,9 @@ константы — потолка одновременно живущих кук, которого в жизни нет и который менти нечем объяснить. - *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не - гарантия — однажды опись покраснеет, а чинить нечем, кроме смены - зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев, - так что реализм этой лотереи невидим. + гарантия — однажды паспорт покраснеет, а чинить нечем, кроме смены зерна; при + этом несклеенная пара в данных неотличима от двух незнакомцев, так что реализм + этой лотереи невидим. - *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор «все пары покупают в первый день» виден в данных ровно там, куда лаба склейки смотрит пристальнее всего. Условность допустима, пока она не @@ -185,31 +182,29 @@ - **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени. Снимок при пересборке побайтово совпадает: канонический порядок ключей и - строк; сверка — по хешам описи, а два локально пересобранных снимка - сравнимы обычным diff — пустой означает «ничего не изменилось». Вне - обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала, - `_load_ts`, темп живого дня. + строк; сверка — по хешам паспорта, а два локально пересобранных снимка + сравнимы обычным diff — пустой означает «ничего не изменилось». Вне обещания — + транспорт: офсеты и партиции Kafka, какая нода прочитала, `_load_ts`, темп + живого дня. - **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock` - и внутри канонического контейнера — то есть везде Linux, на маке и в WSL - тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет - скрипт «пересгенерируй день N — сравни хеш с описью». Расхождение на - любой платформе — баг генератора, а не допуск. + и внутри канонического контейнера — то есть везде Linux, на маке и в WSL тоже; + единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость + любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с + паспортом». Расхождение на любой платформе — баг генератора, а не допуск. - **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток - состава мира, ветвящийся по номеру дня на когорты плана (состав - спрашивается по дням — раздел 1; уточнение при исполнении #38). - Дни предыстории отрицательны, а позиция в дереве — неотрицательное - целое, поэтому у предыстории своя ветвь состава, отдельная от оси - (уточнение при исполнении #38, 2026-08-02); - (зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, - торговые события, заказная и событийная стороны (судьба заказов и порча - событийного потока) — в фиксированном порядке (уточнение [судьбой - заказа](../architecture/orders/fate.md): прежние «расхождения» и - «опоздания» были названы по классам витрины, а компонент называет часть - мира). - По построению: параллельный прогон равен последовательному; продление - истории днём N+1 не трогает дни 1…N; правка одного компонента меняет - только его часть снимка — в описи меняются хеши только затронутых - дней, дифф двух локальных пересборок читаем. + состава мира, ветвящийся по номеру дня на когорты плана (состав спрашивается + по дням — раздел 1; уточнение при исполнении #38). Дни предыстории + отрицательны, а позиция в дереве — неотрицательное целое, поэтому у + предыстории своя ветвь состава, отдельная от оси (уточнение при исполнении + #38, 2026-08-02); (зерно, день) → подпоток дня → именованные подпотоки + компонентов: трафик, торговые события, заказная и событийная стороны (судьба + заказов и порча событийного потока) — в фиксированном порядке (уточнение + [судьбой заказа](../architecture/orders/fate.md): прежние «расхождения» и + «опоздания» были названы по классам витрины, а компонент называет часть мира). + По построению: параллельный прогон равен последовательному; продление истории + днём N+1 не трогает дни 1…N; правка одного компонента меняет только его часть + снимка — в паспорте меняются хеши только затронутых дней, дифф двух локальных + пересборок читаем. - **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей расширением `spawn_key`, потомок полностью определяется парой @@ -223,8 +218,8 @@ представление в клиентском `purchase` (урок мастер-спеки о расхождениях). - **Канонический seed и паспорт мира.** Эталонный мир собирается одним каноническим зерном — константой репозитория; свои зёрна менти крутит без - гарантий описи. Опись хранит паспорт мира — зерно и версию - генератора; чек-скрипты сверяют паспорт раньше счётчиков. + гарантий паспорта. В шапке паспорта лежат зерно и версия генератора; + чек-скрипты сверяют шапку раньше счётчиков. - **Суточный профиль интенсивности задаёт день-функция.** Форма — волны: ночной провал, обеденный и вечерний пики, различие будней и выходных; пики — до ~2× среднего. С детерминизмом профиль совместим: это часть @@ -241,8 +236,8 @@ «параллельно равно последовательно» недостижимо. - *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет - словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная - пересборка описи одним PR. + словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная пересборка + паспорта одним PR. ## 3. Контракт схемы @@ -289,9 +284,9 @@ - **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт упорядоченный поток канонических байтов — единственное место, где событие - превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш - для пересборки и проверок описи), Kafka пачкой — пакетный режим, - Kafka с темпом ×60 — живой день. Новых топиков нет. + превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш для + пересборки и проверок паспорта), Kafka пачкой — пакетный режим, Kafka с темпом + ×60 — живой день. Новых топиков нет. - **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое отдельным сообщением. Контракт транспорта, не деталь реализации — сторона @@ -299,9 +294,9 @@ ([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких событий в одно сообщение сломала бы разбор целиком. - **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`, - `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь - смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и - разбирает событие глазами, а число эпохи этот урок убивает. Колонка + `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырых данных: + весь смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте + и разбирает событие глазами, а число эпохи этот урок убивает. Колонка `ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт Метрика. @@ -321,9 +316,9 @@ первым. Записана форма всё равно здесь — иначе каждый выберет своё, и разойдётся это уже после приёмки. - **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз, - numpy-массивы и datetime сериализует нативно (заметка исследования #31). - Смена библиотеки меняет канонические байты, поэтому проходит как - обновление зависимости: осознанная пересборка описи одним PR. + numpy-массивы и datetime сериализует нативно (заметка исследования #31). Смена + библиотеки меняет канонические байты, поэтому проходит как обновление + зависимости: осознанная пересборка паспорта одним PR. - **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции: потерял — пересчитал. В git снимок не попадает (раздел 5). - **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает @@ -341,7 +336,7 @@ - *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне обещания детерминизма, retention конечен, в git топик не положишь, хеш с - описью не сверишь; Kafka на стенде — труба, не хранилище (раздел 7 + паспортом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7 мастер-спеки). - *Файл как обязательная станция доставки*: доигрывание обрыва уже решено через дедуп, канон держит единственный сериализатор, а не диск; файл @@ -360,27 +355,25 @@ ~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей — правдоподобный средний магазин. - **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник - по понедельник, полная неделя и один закрытый день за окном K = 7. - Обещание в 14 дней снято на этапе 7 (решение владельца 2026-08-30): - у дней 8–13 не нашлось читателя — лабы проигрывают мир дальше без описи, - транспорт байты с описью не сверяет, а проверке «пересгенерируй день N — - сравни хеш» день 3 годится так же, как день 13. Замеры и тесты, меряющие - мир на окне в две недели, не меняются: мир — функция без края, окну - замера опись не нужна. Удлинение описи остается дешевым ходом - (пересборка) и делается, когда читатель появится — например, если - растяжке `classes_vs_inventory` понадобится сверять больше одного - закрытого дня. -- **В git — только опись, снимок не хранится.** Снимок генерируется при + по понедельник, полная неделя и один закрытый день за окном K = 7. Обещание в + 14 дней снято на этапе 7 (решение владельца 2026-08-30): у дней 8–13 не + нашлось читателя — лабы проигрывают мир дальше без паспорта, транспорт байты с + паспортом не сверяет, а проверке «пересгенерируй день N — сравни хеш» день 3 + годится так же, как день 13. Замеры и тесты, меряющие мир на окне в две + недели, не меняются: мир — функция без края, окну замера паспорт не нужен. + Удлинение паспорта остаётся дешёвым ходом (пересборка) и делается, когда + читатель появится — например, если растяжке `classes_vs_inventory` понадобится + сверять больше одного закрытого дня. +- **В git — только паспорт, снимок не хранится.** Снимок генерируется при создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в - git не хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки - «пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней. - Первый `make up` пустого стенда — живая демонстрация детерминизма; готовый мир - повторный подъём не пересчитывает. Честная потеря — - страховка на случай платформенного бага: раньше менти с расходящимися - байтами мог взять готовый снимок из git, теперь он упрётся в красный чек - описи; смягчение — дисциплина целочисленной случайности (раздел 2): - расхождения amd64/arm64 сняты устройством, а не сторожем. CI ради одного - прогона не заводится (решение владельца 2026-08-30). + git не хранят. Паспорт несёт шапку, счётчики и хеши по дням; проверки «пустой + git diff» и «пересгенерируй день N — сравни хеш» живут на нём. Первый `make + up` пустого стенда — живая демонстрация детерминизма; готовый мир повторный + подъём не пересчитывает. Честная потеря — страховка на случай платформенного + бага: раньше менти с расходящимися байтами мог взять готовый снимок из git, + теперь он упрётся в красный чек паспорта; смягчение — дисциплина целочисленной + случайности (раздел 2): расхождения amd64/arm64 сняты устройством, а не + сторожем. CI ради одного прогона не заводится (решение владельца 2026-08-30). - **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты, суточная волна разворачивается на глазах; темп в среднем ~35 событий/с, в пиковые часы сильных дней — до ~100. Число — @@ -399,7 +392,7 @@ | Операция | Ориентир | |---|---| | Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) | -| Пересборка эталонного мира (8 дней + опись, без транспорта) | 20,5 с (замер 30 августа 2026 года) | +| Пересборка эталонного мира (8 дней + паспорт, без транспорта) | 20,5 с (замер 30 августа 2026 года) | | Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) | | Лаг живого дня | секунды | @@ -460,9 +453,9 @@ - **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data contract — хранилище пишется по документации, границу сторожит строгий приём (раздел 3 здесь). -- **Раздел 8**: артефакт `data/startup_history/` в git заменён описью; - снимок генерируется на месте (раздел 5 здесь). Туман «политика - версионирования артефакта» закрыт этим же ходом: версионируется опись. +- **Раздел 8**: артефакт `data/startup_history/` в git заменён паспортом; + снимок генерируется на месте (раздел 5 здесь). Туман «политика версионирования + артефакта» закрыт этим же ходом: версионируется паспорт. - **Раздел 11**: пункт «до этапа 3 зафиксировать требования производительности» закрыт числами раздела 5. - Мелкие согласования там, где текст опирался на артефакт в git: источник @@ -491,24 +484,25 @@ теперь есть. - **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг по расписанию (~раз в 24 минуты); генератор не дорабатывается. -- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 — +- **Позиция на оси времени** (хвост отдан дагам модельного времени раньше этапа + 5 — [ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт - она переменной Airflow: отсутствие переменной означает, что стартовый мир - ещё не создан. В обычной жизни её ставит даг пульта, который сыграл день, — + она переменной Airflow: отсутствие переменной означает, что стартовый мир ещё + не создан. В обычной жизни её ставит шаговый даг, который сыграл день, — значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла - используют `-1` для незавершённой очистки, `0` для чистого мира до генерации - и ставят первый несыгранный день только после приёма стартового мира - ([ADR 0013](../adr/0013-world-lifecycle.md)). - Довод — генератор отдельная и заменяемая сущность, привязывать его к - хранилищу незачем. Полный `make clean` сносит метаданные Airflow вместе с - данными ClickHouse, а узкая пересборка явно сводит сохранённую позицию с - заново залитым миром. - Расхождение переменной с данными (менти почистил партицию руками) лечится - документацией, а не сторожем: постоянная сверка была бы проверкой, красной - в норме ([ADR 0002](../adr/0002-monitoring-scope.md)). -- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт. - CI-генерация на amd64 и arm64 снята — резолюция в разделе 5. + используют `-1` для незавершённой очистки, `0` для чистого мира до генерации и + ставят первый несыгранный день только после приёма стартового мира ([ADR + 0013](../adr/0013-world-lifecycle.md)). Довод — генератор отдельная и + заменяемая сущность, привязывать его к хранилищу незачем. Полный `make clean` + сносит метаданные Airflow вместе с данными ClickHouse, а узкая пересборка явно + сводит сохранённую позицию с заново залитым миром. Расхождение переменной с + данными (менти почистил партицию руками) лечится документацией, а не сторожем: + постоянная сверка была бы проверкой, красной в норме ([ADR + 0002](../adr/0002-monitoring-scope.md)). +- **Этап 7 (эталонный мир)**: пересборка обновляет паспорт мира. Отдельного + артефакта у неё нет. CI-генерация на amd64 и arm64 снята — резолюция в разделе + 5. - **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя — готовая демонстрация идемпотентности конвейера. @@ -521,10 +515,10 @@ [ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора записывают в ADR и в спеке этапа, на котором их принимают. -Журнал собран по темам, но дата и тикет остались у каждого решения. По -журналу видно, какие границы затрагивала каждая правка: иногда она меняла -сразу план состава, воронку и каталог. При #40 это было дешево — опись еще -не собрали; позже такой ход потребовал бы пересборки описи. +Журнал собран по темам, но дата и тикет остались у каждого решения. По журналу +видно, какие границы затрагивала каждая правка: иногда она меняла сразу план +состава, воронку и каталог. При #40 это было дёшево — паспорт мира ещё не +собрали; позже такой ход потребовал бы пересборки паспорта мира. ### План состава и числа мира @@ -547,10 +541,10 @@ покупателей ~190 в день, что дает конверсию ~2% на сессию. - **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с контрактом схемы: все числа мира собраны в одном месте, которое менти может - крутить. Правка модуля меняет мир, и проверка описи честно краснеет: опись - сторожит только канон. Вне модуля остается то, что мира не меняет, — свое - зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и - переопределения окружением — переменная мира, которой нет в паспорте описи; + крутить. Правка модуля меняет мир, и проверка паспорта честно краснеет: + паспорт сторожит только канон. Вне модуля остается то, что мира не меняет, — + свое зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и + переопределения окружением — переменная мира, которой нет в шапке паспорта; файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и валидацией (довод раздела 3 против YAML). - **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение @@ -749,8 +743,8 @@ четвертям цены и по медиане, потому что одни только края списка обманываются четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять - правкой; описи нужен хеш каталога (хвост #42) тем более, потому что теперь в - файле живёт ещё и поведение. + правкой; паспорту нужен хеш каталога (хвост #42) тем более, потому что теперь + в файле живёт ещё и поведение. - **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%** (#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои позиции, а визит с намерением должен класть почти все сравненное; обе границы @@ -764,7 +758,7 @@ живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий предел считается в другой валюте — дневной бюджет событий, на котором стоит - опись #42. Если будущая правка упрется в него, двигать надо бюджет и его + паспорт #42. Если будущая правка упрется в него, двигать надо бюджет и его причины, а не долю. ### Номер заказа, промокод и деньги @@ -933,28 +927,27 @@ подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине. -### Опись мира и словарь +### Паспорт мира и словарь -- **Опись мира одна** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7, - 2026-08-30). Стартовый мир этапа 2 — первые восемь дней, - понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7. - `data/world-inventory.json` — та самая опись, которую обещает раздел 5. - Этап 7 оставил ее восьмидневной: у хвоста в шесть дней не нашлось читателя - (резолюция — в разделе 5); вырастет она продолжением этого же файла, а не - вторым. В JSON лежат - паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с - датой, числом событий и хешем. Опись собирает `make inventory`, свежесть - сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки. - Отклонено: *два файла, младший и полный* — две правды об одном мире и лишнее - слово в словаре. +- **Паспорт мира один** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7, + 2026-08-30). Стартовый мир этапа 2 — первые восемь дней, понедельник по + понедельник: полная неделя и первый замкнутый цикл K = 7. + `data/world-inventory.json` — тот самый паспорт, который обещает раздел 5. + Этап 7 оставил его восьмидневным: у хвоста в шесть дней не нашлось читателя + (резолюция — в разделе 5); вырастет он продолжением этого же файла, а не + вторым. В JSON лежат шапка паспорта — зерно, версия генератора и хеш каталога + — и строки дней с датой, числом событий и хешем. Паспорт собирает `make + inventory`, свежесть сторожит `test_inventory.py` тем же способом, что + свежесть описания выгрузки. Отклонено: *два файла, младший и полный* — две + правды об одном мире и лишнее слово в словаре. - **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07). Правка цены меняет мир так же молча, как правка кода. Хеши дней и без каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что менялось: разошлись дни и каталог — CSV, только дни — код. - **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»** - (#42, решение владельца 2026-08-07). Обычные русские слова убирают пару - «манифест / мини-манифест» и необходимость различать две сущности там, где - вещь одна. + (#42, решение владельца 2026-08-07; позднее тикет #214 сменил «опись мира» на + «паспорт мира»). Обычные русские слова убирают пару «манифест / мини-манифест» + и необходимость различать две сущности там, где вещь одна. ### Открытое на момент заморозки diff --git a/docs/specs/2026-08-21-world-lifecycle.md b/docs/specs/2026-08-21-world-lifecycle.md index 1837e58..387c116 100644 --- a/docs/specs/2026-08-21-world-lifecycle.md +++ b/docs/specs/2026-08-21-world-lifecycle.md @@ -12,11 +12,11 @@ DDL и двумя одноразовыми службами заливал во Граница владения не совпадала с процессом, и это видно по следствиям. Успешная отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу -после записи в топик, до приёма сообщений хранилищем. -Повторный `make up` смешивал подъём служб с повторной заливкой данных. Узкая -пересборка хранилища сохраняла в Kafka сообщения и офсеты прежнего мира, потому -что чинила только ClickHouse. Позиция пульта правилась снаружи Airflow — тем, -кто миром не управляет. +после записи в топик, до приёма сообщений хранилищем. Повторный `make up` +смешивал подъём служб с повторной заливкой данных. Узкая пересборка хранилища +сохраняла в Kafka сообщения и офсеты прежнего мира, потому что чинила только +ClickHouse. Позиция на оси правилась снаружи Airflow — тем, кто миром не +управляет. **Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат прикладная схема, данные мира и позиция модельного времени, — и Compose, @@ -63,7 +63,7 @@ Airflow держит прикладные топики Kafka, каноничес Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`, `data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого -собирается канонический образ генератора, из второго читается опись мира, а +собирается канонический образ генератора, из второго читается паспорт мира, а последний не пускает в контекст сборки локальные окружения и кэши. Запросы стенда приезжают прежним монтированием `sql/`. @@ -101,7 +101,7 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов кода: сборкой образа при подъёме больше никто не занимается. 5. Играет стартовые дни одним прогоном `batch` того же образа. 6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего - сценария, и оно точное: сумма строк `ods.event_dist` за даты описи и строк + сценария, и оно точное: сумма строк `ods.event_dist` за даты паспорта и строк `ods.event_errors_dist` не меньше суммы событий из [`data/world-inventory.json`](../../data/world-inventory.json). «Что-то приехало» условием не считается. @@ -136,21 +136,21 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов третьим местом с той же последовательностью, а первое же расхождение между дагами пришлось бы прятать в его параметрах. -### Сторожа у работников мира +### Сторожа у шаговых дагов `world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше `WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг: -поднять стенд или пересобрать хранилище. Выключатель `world_live` своего -сторожа не получает — он краснеет вслед за работником. +поднять стенд или пересобрать хранилище. Выключатель `world_live` своего сторожа +не получает — он краснеет вслед за шаговым дагом. `orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда готового мира ещё нет. ## Состояния позиции -`world_position` остаётся единственным состоянием пульта. +`world_position` остаётся единственным состоянием дагов модельного времени. -| Позиция | `world_initialize` | `world_recreate` | Работники | +| Позиция | `world_initialize` | `world_recreate` | Шаговые даги | |---|---|---|---| | переменной нет | создаёт мир | очищает и создаёт | красный отказ | | `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ | @@ -187,9 +187,9 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо. Поэтому сначала удаляются базы, затем топики. -**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: сырьё -в STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на непустом -ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта +**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: число +строк STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на +непустом ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти делает себе сам и тут же видит. @@ -197,10 +197,10 @@ ODS истинно сразу — этим оно и ограничено, об `world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline` и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот. -Одновременная пересборка добавляет ожидания и загоняет стенд в тупик. -Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live` -на паузу и даёт работникам доиграть. Предусловие остаётся в документации; -механизм блокировок стоил бы дороже этой строки. +Одновременная пересборка добавляет ожидания и загоняет стенд в тупик. Поэтому +пересборка — явная операция обслуживания: оператор ставит `world_live` на паузу +и даёт шаговым дагам доиграть. Предусловие остаётся в документации; механизм +блокировок стоил бы дороже этой строки. ## Границы @@ -226,9 +226,9 @@ ODS истинно сразу — этим оно и ограничено, об позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что мир уже есть. - **`make rebuild-storage`** возвращает канонический мир: позиция проходит - `-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы + `-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты паспорта, топики созданы заново, офсеты групп идут с нуля, DDS и DM собраны. -- **Красные пути проверяются своими отказами.** Сторож работника при +- **Красные пути проверяются своими отказами.** Сторож шагового дага при отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`; оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки, доказывает только то, что умеет печатать «ЗЕЛЁНО». diff --git a/docs/specs/2026-09-01-labs-program.md b/docs/specs/2026-09-01-labs-program.md index ab1c3b7..a0380b8 100644 --- a/docs/specs/2026-09-01-labs-program.md +++ b/docs/specs/2026-09-01-labs-program.md @@ -104,14 +104,14 @@ ### Ступень 6. Разбери сырые события и сверь результат со стендом -Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно +Менти пакетно разбирает срез событий из STG в собственную таблицу и построчно сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока отдельно от живого приема и использовать стендовый результат как образец для сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет сообщения. Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее -границы называются прямо: срок работы ограничен окном хранения сырья, а +границы называются прямо: срок работы ограничен окном хранения сырых данных, а содержимое неопорных колонок можно сравнить только со стендовым разбором, не с независимым эталоном. @@ -150,7 +150,8 @@ - **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно разложить до конкретной вычеркнутой позиции и объяснить до копейки. - **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а - некоторые различия после истечения срока сырья уже не восстановить. + некоторые различия после истечения срока хранения сырых данных уже не + восстановить. - **Когда отчет за день можно считать окончательным (#188).** Отчет за день становится окончательным лишь после того, как источник больше не может изменить этот день. @@ -191,7 +192,8 @@ потребителей создает еще одну полную копию потока. - **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах показывает потерю, которую трудно заметить по самим данным. -- **Почему сырье удаляется по времени загрузки.** Очистка сырья живет по +- **Почему сырые данные удаляются по времени загрузки.** Очистка сырых данных + идёт по времени загрузки, а не по модельному дню. - **Куда попадают битые строки.** Годные строки и брак делят поток без пропусков и пересечений; менти подкладывает по строке каждого класса брака diff --git a/generator/README.md b/generator/README.md index ce419e2..5e4699e 100644 --- a/generator/README.md +++ b/generator/README.md @@ -64,8 +64,8 @@ - `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)): паспорт мира +- `src/clickstream_generator/inventory.py` — сборка паспорта мира + ([`data/world-inventory.json`](../data/world-inventory.json)): шапка паспорта и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов расхождений по заказам для дней с закрытым окном. Руками не правят — пересобирают целью `make inventory`. @@ -88,7 +88,7 @@ - `make typecheck` — ty: проверка типов; - `make demo` — пройти стадии канонического D0 на одном экране; - `make docs` — пересобрать описание выгрузки; -- `make inventory` — пересобрать опись мира. +- `make inventory` — пересобрать паспорт мира. В корне репозитория `make lint` тоже есть, но он про код стенда — даги и Superset. Одноимённые цели за разными дверями охватывают разное: diff --git a/generator/src/clickstream_generator/inventory.py b/generator/src/clickstream_generator/inventory.py index bf3294c..b687dde 100644 --- a/generator/src/clickstream_generator/inventory.py +++ b/generator/src/clickstream_generator/inventory.py @@ -1,15 +1,15 @@ -"""Опись мира: чем стенд наполняется при подъёме и каким это обязано выйти. +"""Паспорт мира: чем стенд наполняется при подъёме и каким это обязано выйти. Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не -он сам, а опись: паспорт мира, число событий и хеш байтов каждого дня, счётчики -классов расхождений по заказам. Сам мир пересчитывается когда угодно, а опись +он сам, а паспорт: шапка, число событий и хеш байтов каждого дня, счётчики +классов расхождений по заказам. Сам мир пересчитывается когда угодно, а паспорт отвечает на единственный вопрос — **тот ли это мир, что был вчера**. Разошлись хеши — мир уехал, и дальше уже неважно, чего от него ждали проверки. -Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по +Дней в паспорте восемь: столько заливается в стенд при `make up`. Понедельник по понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7. Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 — -у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот +у хвоста не нашлось читателя. Понадобится длиннее — паспорт продолжит этот же файл, а не заведет второй. **Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем @@ -30,9 +30,9 @@ Слепок заказов — второй артефакт мира, и хешами дней он не покрыт ни при каком раскладе подпотоков, поэтому у каждого отправленного слепка своя строка. Их на один меньше, чем дней: прогон дня 0 отправлять ещё нечего. Счёта строк у -слепка нет — опись описывает мир, а не доставку. +слепка нет — паспорт описывает мир, а не доставку. -Собирается опись из каталога генератора целью `make inventory`, а свежесть её +Собирается паспорт из каталога генератора целью `make inventory`, а его свежесть сторожит тест — как и у «описания выгрузки». """ @@ -57,7 +57,7 @@ STARTING_DAYS = 8 def build() -> dict[str, Any]: - """Опись целиком: паспорт мира, строка на день и строка на слепок. + """Паспорт целиком: шапка, строка на день и строка на слепок. Дни играются по одному и отпускаются: заказы дня остаются, потому что из них собираются слепки, а полсотни тысяч событий восьми дней сразу в память @@ -83,12 +83,12 @@ def build() -> dict[str, Any]: 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`), и по этой же дате счётчики @@ -105,7 +105,7 @@ def _day(number: int, today: day_module.Day) -> dict[str, Any]: def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]: - """Строка описи слепка: какой день снят, его дата и хеш отправленных байтов. + """Строка паспорта слепка: какой день снят, его дата и хеш отправленных байтов. Байты те же, что уезжают в топик `orders`, с переводом строки после каждого заказа: пересъёмка слепка обязана дать их снова. @@ -169,12 +169,12 @@ def _digest(payload: bytes) -> str: def main() -> None: parser = argparse.ArgumentParser( - description="Собирает опись мира: паспорт, счётчики и хеши дней." + description="Собирает паспорт мира: шапку, счётчики и хеши дней." ) - parser.add_argument("output", type=Path, help="путь к файлу описи") + parser.add_argument("output", type=Path, help="путь к файлу паспорта") output = parser.parse_args().output output.write_text(render(), encoding="utf-8") - print(f"Опись мира собрана: {output}") + print(f"Паспорт мира собран: {output}") if __name__ == "__main__": diff --git a/generator/src/clickstream_generator/player.py b/generator/src/clickstream_generator/player.py index 61957c3..fd47c39 100644 --- a/generator/src/clickstream_generator/player.py +++ b/generator/src/clickstream_generator/player.py @@ -15,7 +15,7 @@ Тайминги печатаются раздельно — генерация и доставка, как требует спека (раздел 5): это разные машины разной природы, и сложенные в одно число они перестают что-либо говорить. Сериализация считается частью генерации: она -рождает те самые байты, которые сторожит опись. +рождает те самые байты, которые сторожит паспорт. **Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за diff --git a/generator/src/clickstream_generator/seeds.py b/generator/src/clickstream_generator/seeds.py index 7398069..25b50d3 100644 --- a/generator/src/clickstream_generator/seeds.py +++ b/generator/src/clickstream_generator/seeds.py @@ -25,8 +25,8 @@ from enum import IntEnum import numpy as np -# Каноническое зерно эталонного мира — константа репозитория; опись хранит -# его в паспорте мира. Свои зёрна менти крутит без гарантий описи. +# Каноническое зерно эталонного мира — константа репозитория; паспорт хранит +# его в своей шапке. Свои зёрна менти крутит без гарантий паспорта. CANONICAL_SEED = 20260601 diff --git a/generator/src/clickstream_generator/serialize.py b/generator/src/clickstream_generator/serialize.py index 3e94b1d..838ebde 100644 --- a/generator/src/clickstream_generator/serialize.py +++ b/generator/src/clickstream_generator/serialize.py @@ -18,7 +18,7 @@ строгий приём хранилища сверяет набор ключей (ADR 0005). - **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как `2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость - сырья: менти открывает колонку `raw` обычным клиентом и разбирает событие + сырых данных: менти открывает колонку `raw` обычным клиентом и разбирает событие глазами, а число эпохи этот урок убивает. - **Одно событие — один документ JSON**, без перевода строки внутри: приёмник сам решает, чем их разделить. diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index 43ff27b..f84ba36 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -2,8 +2,8 @@ Модуль — приглашение крутить: поменяйте число, пересоберите снимок и посмотрите, что стало с данными. Правка любой константы здесь — смена мира, -поэтому чек описи честно покраснеет: опись сторожит только канонический мир, -свои миры менти собирает без её гарантий (спека генератора, раздел 9). +поэтому чек паспорта честно покраснеет: паспорт сторожит только канонический мир, +свои миры менти собирает без его гарантий (спека генератора, раздел 9). Числа решены спекой и связаны между собой; связки сторожат тесты `test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего. @@ -28,7 +28,7 @@ COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara # D0 — первый день оси модельного времени, понедельник. Реальный календарь в # модели не участвует: дата нужна лишь затем, чтобы дни оси легли в # `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не -# зависит — иначе опись перестала бы быть воспроизводимой. +# зависит — иначе паспорт перестал бы быть воспроизводимым. ORIGIN = date(2026, 6, 1) # Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы, diff --git a/generator/tests/test_demo.py b/generator/tests/test_demo.py index 604310d..6afc1b2 100644 --- a/generator/tests/test_demo.py +++ b/generator/tests/test_demo.py @@ -5,7 +5,7 @@ from pathlib import Path def test_demo_runs_and_shows_the_whole_chain() -> None: - """Сторожит команду и стадии, а числа мира оставляет описи.""" + """Сторожит команду и стадии, а проверку чисел мира оставляет паспорту.""" finished = subprocess.run( ["make", "--silent", "demo"], check=False, diff --git a/generator/tests/test_inventory.py b/generator/tests/test_inventory.py index 809e65f..656e621 100644 --- a/generator/tests/test_inventory.py +++ b/generator/tests/test_inventory.py @@ -1,7 +1,7 @@ -"""Проверка описи мира: та ли она, что собирается из кода сегодня. +"""Проверка паспорта мира: совпадает ли он с тем, что собирается из кода сегодня. -Опись собирается из кода, значит разойтись они могут только одним способом — -код правили, опись не пересобрали. Ровно это здесь и сторожится, тем же +Паспорт собирается из кода, значит разойтись они могут только одним способом — +код правили, паспорт не пересобрали. Ровно это здесь и сторожится, тем же способом, что свежесть «описания выгрузки». Проверка дорогая — она пересчитывает восемь модельных дней целиком, и это @@ -23,24 +23,24 @@ from clickstream_generator.seeds import CANONICAL_SEED INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory.json" -# Прогон, слепок которого сверяется с описью байт в байт. День любой из +# Прогон, слепок которого сверяется с паспортом байт в байт. День любой из # отправляемых; этот дешевле прочих — его окно короче. RUN_DAY = 2 -# День любой: хеш дня не зависит ни от соседей, ни от края описи. +# День любой: хеш дня не зависит ни от соседей, ни от края паспорта. REPLAY_DAY = 4 @pytest.fixture(scope="module") def inventory() -> dict: - """Опись, собранная из кода: мир пересчитывается один раз на весь модуль.""" + """Паспорт, собранный из кода: мир пересчитывается один раз на весь модуль.""" return build() def test_inventory_is_up_to_date(inventory: dict): stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8")) assert stored == inventory, ( - "опись мира отстала от кода — пересоберите: make inventory." + "паспорт мира отстал от кода — пересоберите: make inventory." " Разошлись хеши дней и каталога — правили data/catalog/products.csv;" " разошлись только дни — правили генератор" ) @@ -68,7 +68,7 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm """У каждого отправленного слепка — своя строка с хешем его байтов. Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков: это - второй артефакт мира, и побайтовое обещание сторожит опись. Слепков на день + второй артефакт мира, и побайтовое обещание сторожит паспорт. Слепков на день меньше, чем дней: прогон дня 0 не отправляет ничего. Хеш сверяется с настоящей выгрузкой, а не с самим собой: в файл уезжают те @@ -86,7 +86,7 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm def test_a_replayed_day_matches_its_inventory_hash(tmp_path): - """Опись сверяется с байтами, вышедшими из CLI, а не с собранной тут же: + """Паспорт сверяется с байтами, вышедшими из CLI, а не с собранной тут же: пересчет сравнивался бы сам с собой. """ stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8")) diff --git a/generator/tests/test_serialize.py b/generator/tests/test_serialize.py index be03286..65d9bda 100644 --- a/generator/tests/test_serialize.py +++ b/generator/tests/test_serialize.py @@ -28,7 +28,7 @@ def test_every_event_carries_every_column(events): «Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть - канона: от него зависят байты, а значит и хеши описи. + канона: от него зависят байты, а значит и хеши паспорта. """ names = [column.name for column in schema.COLUMNS] for event in events: diff --git a/generator/tests/test_snapshot.py b/generator/tests/test_snapshot.py index fb52077..d6a3e31 100644 --- a/generator/tests/test_snapshot.py +++ b/generator/tests/test_snapshot.py @@ -167,7 +167,7 @@ def test_the_moments_carry_real_milliseconds(days: list[day_module.Day]): def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]): """Порядок строк слепка — порядок рождения заказов, он же рост номера. - Хешу слепка в описи нужен именно названный порядок: детерминизм даёт его + Хешу слепка в паспорте нужен именно названный порядок: детерминизм даёт его даром, но обещание побайтового повтора держится на нём, а не на удаче. """ numbers = [record["order_id"] for record in snapshot_records(days, FULL_WINDOW_DAY)] diff --git a/infra/grafana/provisioning/dashboards/data.json b/infra/grafana/provisioning/dashboards/data.json index 6c9e65a..1c3e16e 100644 --- a/infra/grafana/provisioning/dashboards/data.json +++ b/infra/grafana/provisioning/dashboards/data.json @@ -104,7 +104,7 @@ }, { "type": "timeseries", - "title": "Поток: сколько строк приезжает в слой сырья", + "title": "Поток: сколько строк приезжает в сырой слой", "datasource": { "type": "grafana-clickhouse-datasource", "uid": "clickhouse" diff --git a/scripts/check-clickhouse.sh b/scripts/check-clickhouse.sh index dcb4d6d..63f4ff4 100755 --- a/scripts/check-clickhouse.sh +++ b/scripts/check-clickhouse.sh @@ -102,12 +102,12 @@ on_signal() { # Единственный постоянный сторож цепочки Kafka → STG → ODS. # # Проверка спрашивает не «есть ли данные», а «те ли это данные»: подневный счёт -# в `ods.event` против описи мира, лежащей в git. Поэтому она умеет краснеть на +# в `ods.event` против паспорта мира, лежащего в git. Поэтому она умеет краснеть на # сломанном разборе — уехали события в брак, счёт разошёлся, — и на потере по # дороге, а не только на пустой таблице. # # **Счёт обрамлён датами стартового мира.** Мир растёт: менти переиграет день, -# этап 5 добавит следующий, — и голый счёт по таблице разойдётся с описью +# этап 5 добавит следующий, — и голый счёт по таблице разойдётся с паспортом # законно, без всякой поломки. # # **Счёт идёт через ods.event_v** — каноническую поверхность актуального @@ -138,7 +138,7 @@ check_starting_world() { FORMAT TSV")" if [[ "$actual" == "$expected" ]]; then - printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с описью.\n' + printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с паспортом.\n' return fi @@ -148,13 +148,13 @@ check_starting_world() { GROUP BY error_class ORDER BY count() DESC FORMAT TSV")" - printf 'Опись мира ожидает (дата, событий):\n%s\n' "$expected" >&2 + printf 'Паспорт мира ожидает (дата, событий):\n%s\n' "$expected" >&2 printf 'В ods.event лежит:\n%s\n' "${actual:-— ничего —}" >&2 if [[ -z "$broken" ]]; then - fail 'счёт разошёлся с описью, а таблица брака пуста: события не доехали до ODS — начните с чтеца топика stg.hits_raw_kafka и матвью приёма stg.hits_raw_mv' + fail 'счёт разошёлся с паспортом, а таблица брака пуста: события не доехали до ODS — начните с чтеца топика stg.hits_raw_kafka и матвью приёма stg.hits_raw_mv' fi printf 'В ods.event_errors по классам брака:\n%s\n' "$broken" >&2 - fail 'счёт разошёлся с описью, и в таблице брака есть строки. Сойдётся недостача с их числом — сломан разбор, смотрите матвью ods.event_mv; не сойдётся — брак остался от прежних опытов, а события не доехали: смотрите чтеца топика stg.hits_raw_kafka' + fail 'счёт разошёлся с паспортом, и в таблице брака есть строки. Сойдётся недостача с их числом — сломан разбор, смотрите матвью ods.event_mv; не сойдётся — брак остался от прежних опытов, а события не доехали: смотрите чтеца топика stg.hits_raw_kafka' } assert_ddl_queue_completed() { @@ -282,7 +282,7 @@ assert_equal "$expected_product" "$(query_as_etl clickhouse-01 "$product_sql")" assert_equal "$expected_product" "$(query_as_etl clickhouse-02 "$product_sql")" "словарь товаров не ответил на второй ноде" printf 'ЗЕЛЁНО: известный sku вернул имя, категорию и цену на обеих нодах.\n' -printf 'Проверка 10/10: стартовый мир в ods.event сходится с описью...\n' +printf 'Проверка 10/10: стартовый мир в ods.event сходится с паспортом...\n' check_starting_world printf 'ИТОГ: все 10 проверок кластера ClickHouse прошли.\n' diff --git a/sql/ddl/10-stg-tables.sql b/sql/ddl/10-stg-tables.sql index c040356..9ab7a73 100644 --- a/sql/ddl/10-stg-tables.sql +++ b/sql/ddl/10-stg-tables.sql @@ -1,6 +1,6 @@ --- STG: чтецы топиков hits и orders, таблицы сырья обоих источников. +-- STG: чтецы топиков hits и orders, таблицы STG обоих источников. -- --- Слой сырья ничего не интерпретирует: сообщение ложится строкой, как пришло, +-- Сырой слой ничего не интерпретирует: сообщение ложится строкой, как пришло, -- рядом с метаданными доставки. Довод целиком — ADR 0005, конвенции колонок и -- сроков — docs/architecture/storage.md. @@ -30,9 +30,9 @@ SETTINGS kafka_group_name = 'clickstream_hits', kafka_format = 'RawBLOB'; --- Локальная таблица сырья. +-- Локальная таблица STG. -- --- Движок именно ReplicatedMergeTree, не Replacing: повтор доставки в сырье +-- Движок именно ReplicatedMergeTree, не Replacing: повтор доставки в STG -- обязан быть виден — ради этого слой и заведён. -- -- Служебные колонки не повторяют имён виртуальных (_topic, _partition, @@ -45,7 +45,7 @@ SETTINGS -- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа -- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms — -- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная, --- _load_ts рядом тоже миллисекундная, а слой сырья хранит то, что приехало, и +-- _load_ts рядом тоже миллисекундная, а сырой слой хранит то, что приехало, и -- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не -- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год. -- @@ -115,8 +115,8 @@ SETTINGS kafka_format = 'RawBLOB', kafka_commit_on_select = 1; --- Локальная таблица сырья заказов. Колонки, типы, ключ, нарезка и срок жизни — --- те же, что у сырья событий, и по тем же доводам: docs/architecture/storage.md. +-- Локальная таблица STG заказов. Колонки, типы, ключ, нарезка и срок жизни — +-- те же, что у STG событий, и по тем же доводам: docs/architecture/storage.md. -- -- Своя колонка здесь одна — _load_id, идентификатор пачки загрузки: он равен -- run_id прогона Airflow, который забрал порцию, и по нему разбор в ODS читает diff --git a/sql/ddl/20-ods-tables.sql b/sql/ddl/20-ods-tables.sql index e914116..1e54f7d 100644 --- a/sql/ddl/20-ods-tables.sql +++ b/sql/ddl/20-ods-tables.sql @@ -106,7 +106,7 @@ SAMPLE BY intHash32(ClientID); -- События одной куки при этом остаются на одном шарде, и сессионизация со -- склейкой идентичностей ниже по течению живут локально. -- --- У сырья ключ другой (хеш строки), поэтому сырая строка и разобранное из неё +-- У STG ключ другой (хеш строки), поэтому сырая строка и разобранное из неё -- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв -- сходиться не должны — сверять слои можно только через _dist. CREATE TABLE IF NOT EXISTS ods.event_dist ON CLUSTER clickstream_cluster @@ -125,10 +125,10 @@ ENGINE = Distributed('clickstream_cluster', 'ods', 'event_rep', cityHash64(Clien -- Ключи собственные. Шардирование — cityHash64(raw): ClientID у неразобранной -- строки взять неоткуда. ORDER BY (error_class, kafka_partition, -- kafka_offset): такую таблицу смотрят от класса брака, а внутри класса — по --- координатам доставки. Нарезка по дню загрузки, как у сырья: модельного дня +-- координатам доставки. Нарезка по дню загрузки, как у STG: модельного дня -- у брака тоже нет. -- --- Срок жизни — месяц, вдесятеро дольше сырья. Истеки брак вместе с сырьём — +-- Срок жизни — месяц, вдесятеро дольше сырых данных. Истеки брак вместе с ними — -- к моменту разбирательства не осталось бы ни того, ни другого. CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster ( diff --git a/sql/ddl/30-ods-views.sql b/sql/ddl/30-ods-views.sql index 6b42c12..0e8a0ee 100644 --- a/sql/ddl/30-ods-views.sql +++ b/sql/ddl/30-ods-views.sql @@ -25,7 +25,7 @@ AS SELECT * FROM ods.order_dist FINAL; --- ODS: разбор сырья в событие и в таблицу ошибок. +-- ODS: разбор STG в событие и в таблицу ошибок. -- -- Матвью две, и вместе они обязаны делить поток без зазора и без нахлёста: -- строка, подошедшая обеим, задвоится, а не подошедшая ни одной — исчезнет @@ -168,7 +168,7 @@ SELECT JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity, JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType, JSONExtract(raw, 'ecommerce', 'String') AS ecommerce, - -- Метка загрузки переносится из сырья как есть, а не ставится заново: + -- Метка загрузки переносится из STG как есть, а не ставится заново: -- она отвечает на вопрос «когда строка приехала в хранилище». Поставь -- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос. _load_ts diff --git a/sql/ddl/40-stg-views.sql b/sql/ddl/40-stg-views.sql index bb8db44..4a20282 100644 --- a/sql/ddl/40-stg-views.sql +++ b/sql/ddl/40-stg-views.sql @@ -1,10 +1,10 @@ --- STG: матвью приёма — из чтеца в сырьё. +-- STG: матвью приёма — из чтеца в таблицу STG. -- -- Номер 40, а не 11, и пропуск в нумерации намеренный. Файлы 20-ods-tables.sql -- и 30-ods-views.sql приносит #43, и матвью приёма обязана создаваться после -- матвью разбора: Kafka-движок начинает читать топик ровно тогда, когда к нему -- привязывают первую матвью. Создай её раньше разбора — и всё, что доедет в --- зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. +-- зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в ошибки. -- На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет: -- проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, то есть на -- обычной отладке. Нумерацию здесь не «приводить в порядок». diff --git a/sql/dds/session_scope.sql b/sql/dds/session_scope.sql index 4079e32..f37425d 100644 --- a/sql/dds/session_scope.sql +++ b/sql/dds/session_scope.sql @@ -17,7 +17,7 @@ -- Позиция — номер первого несыгранного дня, счётчик прожитого. Своей копии -- начала оси стенд из-за неё не заводит: начало он спрашивает у данных, и -- дата дня N в них — константа, начало оси плюс N. Держится якорь на том, что --- ранние дни из ods.event не исчезают: срок жизни назначен сырью и таблицам +-- ранние дни из ods.event не исчезают: срок жизни назначен сырым данным и таблицам -- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql). -- -- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма diff --git a/sql/dq/classes_vs_inventory.sql b/sql/dq/classes_vs_inventory.sql index a53217a..e0bc395 100644 --- a/sql/dq/classes_vs_inventory.sql +++ b/sql/dq/classes_vs_inventory.sql @@ -1,8 +1,8 @@ --- DQ: стендовая сторона технической растяжки от описи мира. +-- DQ: стендовая сторона технической растяжки от паспорта мира. -- -- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего -- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону; --- Python-задача сопоставляет ее с пятью счетчиками закрытого дня из описи. +-- Python-задача сопоставляет ее с пятью счетчиками закрытого дня из паспорта. SELECT order_day AS data_date, mismatch_class, diff --git a/sql/ods/order_parse.sql b/sql/ods/order_parse.sql index 2b757f1..a5a23b5 100644 --- a/sql/ods/order_parse.sql +++ b/sql/ods/order_parse.sql @@ -124,7 +124,7 @@ SELECT _load_ts FROM stg.orders_raw_dist WHERE _load_id = {load_id:String} AND NOT row_is_valid --- Тот же ноль: ключ брака сегодня совпадает с ключом сырья, и локальная +-- Тот же ноль: ключ брака сегодня совпадает с ключом STG, и локальная -- запись легла бы верно — но по совпадению, а не по контракту. SETTINGS distributed_foreground_insert = 1, parallel_distributed_insert_select = 0; diff --git a/sql/stg/orders_raw_load.sql b/sql/stg/orders_raw_load.sql index 1a458d4..b6a2223 100644 --- a/sql/stg/orders_raw_load.sql +++ b/sql/stg/orders_raw_load.sql @@ -1,4 +1,4 @@ --- Забор порции слепка заказов из Kafka в сырьё STG. +-- Забор порции слепка заказов из Kafka в STG. -- -- Забор — один прямой SELECT, без цикла до пустоты: одна порция ClickHouse -- берёт десятки тысяч сообщений, а слепок дня — порядка полутора тысяч строк.