docs(glossary): четыре термина переименованы, world.md и stand.md вычитаны #215

Merged
ddmitry merged 1 commits from 214-glossary-terms into main 2026-09-02 18:09:14 +03:00
54 changed files with 794 additions and 776 deletions

No files matched your search

+24 -11
View File
@@ -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 не выдаётся.
_Избегать_: DQ-проверка — та работает на наблюдаемых данных магазина. _Избегать_: DQ-проверка — та работает на наблюдаемых данных магазина.
+20 -19
View File
@@ -35,12 +35,12 @@ Prometheus, Grafana, экспортёр метрик Kafka и общая баз
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
хватает, стенд одинаково хорошо живёт на недорогом VPS. хватает, стенд одинаково хорошо живёт на недорогом VPS.
Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`, Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`, `grep`, `sed`,
`grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им читается паспорт
читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны мира, по которому он ждёт заливки. По умолчанию должны быть свободны порты
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` и
и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне, `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне, всем
всем целям генератора в `generator/` — нужен `uv`. целям генератора в `generator/` — нужен `uv`.
Сначала скопируйте настройки стенда: Сначала скопируйте настройки стенда:
@@ -61,7 +61,8 @@ make check-services
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь, свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
кроме [пульта мира](docs/architecture/world.md#пульт-мира). `compose.yaml` не кроме [дагов модельного
времени](docs/architecture/world.md#даги-модельного-времени). `compose.yaml` не
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile. сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
@@ -79,28 +80,28 @@ make check-services
поднятый стенд, сколько стоит прогон и куда класть новую — в поднятый стенд, сколько стоит прогон и куда класть новую — в
[карте проверок](docs/architecture/testing.md). [карте проверок](docs/architecture/testing.md).
Остановить контейнеры без удаления данных можно командой `make down`. Если Остановить контейнеры без удаления данных можно командой `make down`. Если нужно
нужно вернуть прикладной мир к началу, `make rebuild-storage` запустит в вернуть прикладной мир к началу, `make rebuild-storage` запустит в Airflow его
Airflow его явную пересборку: удалит прикладные базы ClickHouse, топики и явную пересборку: удалит прикладные базы ClickHouse, топики и позиции
позиции потребителей Kafka, затем создаст схему и данные заново. Службы и их потребителей Kafka, затем создаст схему и данные заново. Службы и их тома
тома останутся на месте. `make clean` удаляет весь стенд вместе с именованными останутся на месте. `make clean` удаляет весь стенд вместе с именованными томами
томами и для обычной разработки не нужен. Перед пересборкой поставьте и для обычной разработки не нужен. Перед пересборкой поставьте `world_live` на
`world_live` на паузу и дайте уже начатому работнику закончить день. паузу и дайте уже начатому шаговому дагу закончить день.
## Стартовый мир ## Стартовый мир
Стартовый мир и его опись описаны в Стартовый мир и его паспорт описаны в [справочнике
[справочнике мира](docs/architecture/world.md). мира](docs/architecture/world.md).
## Как позвать генератор ## Как позвать генератор
Режимы и команды запуска генератора описаны в Режимы и команды запуска генератора описаны в
[справочнике мира](docs/architecture/world.md#как-запустить-генератор). [справочнике мира](docs/architecture/world.md#как-запустить-генератор).
### Пульт мира ### Даги модельного времени
Даги пульта, позиция мира и форма запуска описаны в Даги времени, позиция мира и форма запуска описаны в [справочнике
[справочнике мира](docs/architecture/world.md#пульт-мира). мира](docs/architecture/world.md#даги-модельного-времени).
## Состав и доступ ## Состав и доступ
+3 -3
View File
@@ -34,7 +34,7 @@ x-clickhouse-common: &clickhouse-common
retries: 30 retries: 30
start_period: 10s start_period: 10s
# Факты генератора и пульта мира названы по одному разу — иначе они однажды # Факты генератора и дагов модельного времени названы по одному разу — иначе они однажды
# разойдутся, и заметит это не проверка, а менти с пустым топиком. # разойдутся, и заметит это не проверка, а менти с пустым топиком.
x-generator-image: &generator-image clickstream-generator:local x-generator-image: &generator-image clickstream-generator:local
x-orders-topic: &orders-topic orders x-orders-topic: &orders-topic orders
@@ -70,7 +70,7 @@ x-airflow-common: &airflow-common
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env} AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
# Пульт мира поднимает генератор отдельным контейнером. Имя сети собирается # Даги времени поднимают генератор отдельным контейнером. Имя сети собирается
# из имени проекта: у второй копии стенда оно другое. # из имени проекта: у второй копии стенда оно другое.
<<: *kafka-target <<: *kafka-target
GENERATOR_IMAGE: *generator-image GENERATOR_IMAGE: *generator-image
@@ -291,7 +291,7 @@ services:
airflow-init: airflow-init:
condition: service_completed_successfully condition: service_completed_successfully
command: scheduler command: scheduler
# Пульт мира зовёт генератор отдельным контейнером, а при LocalExecutor # Даги времени зовут генератор отдельным контейнером, а при LocalExecutor
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному. # задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root # Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660 # на машине. Открывает дверь не монтирование, а группа: у сокета права 660
+4 -4
View File
@@ -46,8 +46,8 @@ HITS_PARTITIONS = (0, 1)
READINESS_TIMEOUT_SECONDS = 300 READINESS_TIMEOUT_SECONDS = 300
READINESS_POLL_SECONDS = 3 READINESS_POLL_SECONDS = 3
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит # Позиция на оси модельного времени: номер первого несыгранного дня. Её ставят
# пульт мира, и только она отличает прожитый день от живого — зачем это # даги модельного времени, и только она отличает прожитый день от живого — зачем это
# сессиям, разобрано в sql/dds/session_scope.sql. # сессиям, разобрано в sql/dds/session_scope.sql.
WORLD_POSITION = "world_position" WORLD_POSITION = "world_position"
@@ -224,8 +224,8 @@ def _wait_for_phase(
def _world_position() -> int: def _world_position() -> int:
"""Позиция мира числом, с внятным отказом на непригодном значении. """Позиция мира числом, с внятным отказом на непригодном значении.
Ту же переменную читает пульт, но общего модуля у дагов нет намеренно: Ту же переменную читают даги времени, но общего модуля у дагов нет намеренно:
связь между пультом и трансформацией держит сама переменная, а не импорт. связь между дагами времени и трансформацией держит сама переменная, а не импорт.
""" """
raw_position = Variable.get(WORLD_POSITION, default=None) raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None: if raw_position is None:
+1 -1
View File
@@ -148,7 +148,7 @@ def build_classes_vs_inventory() -> list[str]:
for mismatch_class, count in day["orders"].items() for mismatch_class, count in day["orders"].items()
} }
if not expected: 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") query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
# GROUP BY закономерно не возвращает группу из нуля строк. # GROUP BY закономерно не возвращает группу из нуля строк.
+2 -2
View File
@@ -1,9 +1,9 @@
"""Приём заказов: пакетный забор слепка из Kafka в хранилище. """Приём заказов: пакетный забор слепка из Kafka в хранилище.
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор
порции в сырьё, затем разбор того же среза в типизированный ODS и в таблицу порции в `stg`, затем разбор того же среза в типизированный ODS и в таблицу
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, — брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, —
работник пульта мира после проигрыша дня, — и ждёт конца. шаговый даг модельного времени после проигрыша дня, — и ждёт конца.
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда Контраст с приёмом событий и есть урок. События тянет матвью, навсегда
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов
+15 -15
View File
@@ -1,25 +1,25 @@
"""Пульт мира: даги, которыми двигают ось модельного времени. """Даги модельного времени: ими двигают ось мира.
Их три, и они двух родов. **Работники** играют день и запускаются руками: Их три, и они двух родов. **Шаговые даги** играют день и запускаются руками:
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` — `world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
один день в темпе модельного времени. **Выключатель** `world_live` своей работы один день в темпе модельного времени. **Выключатель** `world_live` своей работы
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь не делает: он тикает по расписанию и дёргает шаговый даг живого дня, дожидаясь
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
границе модельных суток. границе модельных суток.
Сыграть день — половина работы. Вторая половина: отправить слепок заказов и Сыграть день — половина работы. Вторая половина: отправить слепок заказов и
дождаться, пока хранилище его заберёт. Поэтому у обоих работников за проигрышем дождаться, пока хранилище его заберёт. Поэтому у обоих шаговых дагов за проигрышем
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
`orders_ingest`. `orders_ingest`.
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
только разгоняет источники: слои после нее менти догоняет ручным запуском. только разгоняет источники: слои после нее менти догоняет ручным запуском.
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку Разделение не косметическое. Расписание на самом шаговом даге заставило бы кнопку
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а шаговый даг
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день. при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник Календарь Airflow к оси мира отношения не имеет: какой день играть, шаговый даг
спрашивает у переменной, а не у логической даты прогона. спрашивает у переменной, а не у логической даты прогона.
Решения и доводы целиком — ADR 0009. Решения и доводы целиком — ADR 0009.
@@ -51,7 +51,7 @@ ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"]) 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) LIVE_TICK = datetime.timedelta(minutes=25)
# Какой день играть, работник узнаёт у первой задачи. Спрашивают её все # Какой день играть, шаговый даг узнаёт у первой задачи. Спрашивают её все
# генераторные шаги: слепок снимается с той же позиции, что и проигрыш. # генераторные шаги: слепок снимается с той же позиции, что и проигрыш.
PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}" PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC) START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
TAGS = ["пульт мира"] TAGS = ["модельное время"]
@task @task
@@ -120,7 +120,7 @@ def _generator(task_id: str, command: list[str]) -> DockerOperator:
def _ingest_orders() -> TriggerDagRunOperator: def _ingest_orders() -> TriggerDagRunOperator:
"""Задача забора: дёрнуть даг приёма и дождаться, чем он кончился. """Задача забора: дёрнуть даг приёма и дождаться, чем он кончился.
Ожидание здесь несущее. Без него работник позеленел бы, не узнав, доехал Ожидание здесь несущее. Без него шаговый даг позеленел бы, не узнав, доехал
ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа
не должен переживать отказ выше (ADR 0003). не должен переживать отказ выше (ADR 0003).
""" """
@@ -128,7 +128,7 @@ def _ingest_orders() -> TriggerDagRunOperator:
task_id="trigger_orders_ingest", task_id="trigger_orders_ingest",
trigger_dag_id="orders_ingest", trigger_dag_id="orders_ingest",
wait_for_completion=True, wait_for_completion=True,
# Умолчание — минута опроса, а весь прогон работника пачкой короче. # Умолчание — минута опроса, а весь прогон шагового дага пачкой короче.
poke_interval=10, poke_interval=10,
) )
@@ -151,7 +151,7 @@ def _ingest_orders() -> TriggerDagRunOperator:
# (params.rst, Airflow 3.3), чтобы правило было видно сразу. # (params.rst, Airflow 3.3), чтобы правило было видно сразу.
description_md=( description_md=(
"Форма задает только число дней. Номер первого несыгранного " "Форма задает только число дней. Номер первого несыгранного "
"дня работник берет из переменной `world_position` (Admin → " "дня шаговый даг берёт из переменной `world_position` (Admin → "
"Variables). Поля Logical date и Run ID нужны самому Airflow " "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: модельные сутки укладываются примерно в двадцать четыре Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
реальные минуты, и суточная волна разворачивается на глазах. Запускается реальные минуты, и суточная волна разворачивается на глазах. Запускается
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`. руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
Следующий день работник берет из `world_position`. Логическая дата прогона Следующий день шаговый даг берёт из `world_position`. Логическая дата прогона
на выбор дня не влияет. на выбор дня не влияет.
""" """
+1 -1
View File
@@ -185,7 +185,7 @@ def world_initialize():
@task @task
def wait_for_events() -> None: def wait_for_events() -> None:
"""Дождаться приёма всех событий описи, включая строки брака.""" """Дождаться приёма всех событий паспорта, включая строки брака."""
inventory = json.loads(INVENTORY.read_text(encoding="utf-8")) inventory = json.loads(INVENTORY.read_text(encoding="utf-8"))
expected = sum(day["events"] for day in inventory["days"]) expected = sum(day["events"] for day in inventory["days"])
first_date = inventory["days"][0]["date"] first_date = inventory["days"][0]["date"]
+4
View File
@@ -2,6 +2,10 @@
Дата: 3 августа 2026 года. Статус: принято. Дата: 3 августа 2026 года. Статус: принято.
Позднее тикет #214 сменил имя слоя: вместо «сырья» — сырые данные, сырой слой,
STG. Текст ниже оставлен в словах своего времени, действующее имя — в
`CONTEXT.md`.
## Решение ## Решение
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
+4
View File
@@ -3,6 +3,10 @@
Дата: 12 августа 2026 года. Статус: частично заменено Дата: 12 августа 2026 года. Статус: частично заменено
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93. [ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
Позднее тикет #214 сменил имена: «сырьё» стало сырыми данными, «работник» —
шаговым дагом. Текст ниже оставлен в словах своего времени, действующие
имена — в `CONTEXT.md`.
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
+4
View File
@@ -2,6 +2,10 @@
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79. Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
Позднее тикет #214 сменил имена: «пульт мира» стал дагами модельного времени,
«работник» — шаговым дагом. Текст ниже оставлен в словах своего времени,
действующие имена — в `CONTEXT.md`.
## Решение ## Решение
Ось модельного времени двигают даги пульта, и они двух родов. Ось модельного времени двигают даги пульта, и они двух родов.
+3 -3
View File
@@ -77,9 +77,9 @@ ODS; точный тип и проверка описаны в разделе
- **`Sign`** — колонка формата без механики: генератор исправлений не шлёт, - **`Sign`** — колонка формата без механики: генератор исправлений не шлёт,
и в модели события колонке ответить нечего. Честный рассказ о ней живёт в и в модели события колонке ответить нечего. Честный рассказ о ней живёт в
DDL ODS и лекции. DDL ODS и лекции.
- **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы - **`ecommerce`** — сырой JSON из STG. Это содержимое источника; отдельным
`purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри атрибутом модели оно не становится. Разобранные массивы `purchase_*` и
колонки читает ODS. `product_*` в контракте есть; лаба про разбор JSON внутри колонки читает ODS.
- **`_load_ts`** — у представления нет собственной загрузки, а чужие - **`_load_ts`** — у представления нет собственной загрузки, а чужие
координаты слой не переносит (правило слоя — [модель координаты слой не переносит (правило слоя — [модель
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
+6 -6
View File
@@ -75,12 +75,12 @@ ODS: это единственный устойчивый ключ сущнос
**служебные колонки — координаты собственной загрузки слоя; чужие координаты **служебные колонки — координаты собственной загрузки слоя; чужие координаты
спрашиваются у их слоя по ключу сущности**. спрашиваются у их слоя по ключу сущности**.
Происшествие качества данных расследуется цепочкой, где каждый шаг Происшествие качества данных расследуется цепочкой, где каждый шаг спрашивает
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой свой слой на его языке: по `_load_id` строки DDS видно, какой запуск собрал
запуск собрал день; по `order_id` ODS показывает физические версии и брак — день; по `order_id` ODS показывает физические версии и брак — там свой
там свой `_load_id` приёма связывает годные строки, ошибки и сырьё; дальше `_load_id` приёма связывает годные строки, ошибки и данные STG; дальше читают
сырьё STG, пока живо. Окно расследования задано сроками хранения: сырьё — STG, пока данные живы. Окно расследования задано сроками хранения: сырые данные
трое суток, брак — месяц ([дока хранилища](../storage.md)). — трое суток, брак — месяц ([дока хранилища](../storage.md)).
## Читатели ## Читатели
+9 -8
View File
@@ -176,9 +176,10 @@ Distributed-таблица вернула бы каждый непарный з
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк `uniqExact(VisitID)` из `ods.event_v`, объект — число строк
`dds.session_v`; `dds.session_v`;
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из - `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
`orders` в описи мира, объект — число заказов класса в `orders` в паспорте мира, объект — число заказов класса в
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`, `dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
`duplicate_event` и `amount_delta` только для покрытых описью закрытых дней; `duplicate_event` и `amount_delta` только для покрытых паспортом закрытых
дней;
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются - `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`; `orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые - `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
@@ -194,14 +195,14 @@ Distributed-таблица вернула бы каждый непарный з
сопоставления деловых ключей. сопоставления деловых ключей.
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она `classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет
сам генератор: у настоящего магазина такого оракула нет. сам генератор: у настоящего магазина такого оракула нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
только сводку и малый срез диагностики. Техническая сверка с описью читает сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой
небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг
успеха даг заменяет проверенные дневные партиции. Затем именованные задачи заменяет проверенные дневные партиции. Затем именованные задачи читают
читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
сохраниться до красного завершения, а ошибка сборки не затирает прежний сохраниться до красного завершения, а ошибка сборки не затирает прежний
результат. Красная задача печатает не больше двадцати нарушенных ключей за результат. Красная задача печатает не больше двадцати нарушенных ключей за
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
+12 -12
View File
@@ -37,13 +37,13 @@ world_next_day ─────> источники без ETL
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним - **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт, шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
зеленеть, не узнав, разложился ли день, работник не должен зеленеть, не узнав, разложился ли день, шаговый даг не должен ([ADR
([ADR 0003](../adr/0003-dag-run-state.md)). 0003](../adr/0003-dag-run-state.md)). Такой отказ чинится перезапуском
Такой отказ чинится перезапуском `etl_pipeline` рукой — перезапуску ничего `etl_pipeline` рукой — перезапуску ничего не нужно помнить, см. «Объём
не нужно помнить, см. «Объём прогона». прогона».
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: пульт управляет - **Разгон пачкой.** `world_next_day` конвейер не дёргает: даги модельного
генератором и только им. После разгона менти запускает конвейер сам — времени управляют генератором и только им. После разгона менти запускает
и видит, как слои догоняют уехавший вперёд мир. конвейер сам — и видит, как слои догоняют уехавший вперёд мир.
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом: - **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки `make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
ручного запуска начинаются со следующих дней. ручного запуска начинаются со следующих дней.
@@ -145,9 +145,9 @@ ODS и ещё не собранный в DDS, затем при необходи
## Отклонённые варианты ## Отклонённые варианты
- **Задачи трансформаций прямо в работниках пульта.** Один зелёный прогон — - **Задачи трансформаций прямо в шаговых дагах.** Один зелёный прогон —
весь день, но пульт — про ось времени: работников два, шаги дублировались весь день, но даги времени отвечают за ось времени: шаговых дагов два, шаги
бы, а этапу 5 пришлось бы выдирать их обратно. дублировались бы, а этапу 5 пришлось бы выдирать их обратно.
- **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с - **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с
Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом
режиме слои копили бы отставание молча. режиме слои копили бы отставание молча.
@@ -163,8 +163,8 @@ ODS и ещё не собранный в DDS, затем при необходи
- [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не - [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) — жизненный цикл мира: почему - [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему
прикладным процессом владеет Airflow и почему `world_initialize` кончается прикладным процессом владеет Airflow и почему `world_initialize` кончается
собранными слоями. собранными слоями.
+1 -1
View File
@@ -42,7 +42,7 @@
Новый бросок в середине функции сдвигает все последующие значения этого Новый бросок в середине функции сдвигает все последующие значения этого
подпотока. Код продолжает работать, но тем же свойствам достается другая подпотока. Код продолжает работать, но тем же свойствам достается другая
случайность. Соседние подпотоки остаются прежними, а измененная часть случайность. Соседние подпотоки остаются прежними, а измененная часть
канонического мира проявляется в описи и побайтовом сравнении. канонического мира проявляется в паспорте и побайтовом сравнении.
Какое обещание воспроизводимости это защищает, записано в Какое обещание воспроизводимости это защищает, записано в
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно [спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
+14 -14
View File
@@ -19,23 +19,23 @@
## Почему слои у панелей разные ## Почему слои у панелей разные
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань длиннее: в STG данные привозит забор, а типизированную строку кладёт разбор.
разбор — свежесть ODS это покажет, свежесть сырья нет. Встань разбор — свежесть ODS это покажет, а свежесть STG — нет.
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
здесь про доставку. Местом диагностики доставленных версий физическая таблица здесь про доставку. Местом диагностики доставленных версий физическая таблица
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`). названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего Поток и брак считаются по сырому слою. Он хранит каждую доставку и ничего не
не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, и
и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
сильнее, чем дольше живёт стенд. сильнее, чем дольше живёт стенд.
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
отдельная куча, а часть сырья, которая не прошла разбор. отдельная куча, а часть сырых данных, которая не прошла разбор.
Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое Окно у панели брака — окно дашборда, и это не косметика. Сырые данные живут трое
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
приехавшего и однажды перевалила бы за сотню процентов. приехавшего и однажды перевалила бы за сотню процентов.
@@ -51,7 +51,7 @@
укладываются в минуты реального времени, и соседние дни мира помещаются в одно укладываются в минуты реального времени, и соседние дни мира помещаются в одно
окно панели сами. окно панели сами.
## Приземлившееся считают по слою сырья ## Приземлившееся считают в сыром слое
Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам
экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka` экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka`
@@ -66,7 +66,7 @@
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse. Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
стороны ClickHouse — `count()` строк сырья по тому же интервалу. стороны ClickHouse — `count()` строк STG по тому же интервалу.
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase` Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
растягивает приращение на всё окно и на минутном интервале промахивается на растягивает приращение на всё окно и на минутном интервале промахивается на
@@ -87,11 +87,11 @@ Kafka» приходит из Prometheus, ряд «приземлилось в C
Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в
один столбец не значит ничего. один столбец не значит ничего.
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден сразу.
сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему другой — не
другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него отвечает своя
отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё панель, и модельный день она берёт из самого сообщения: в STG он приезжает полем
он приезжает полем `snapshot_date`. `snapshot_date`.
## Что проверено ## Что проверено
+3 -3
View File
@@ -30,9 +30,9 @@
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы, `ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
пересечения выходят арифметикой, приоритет классов работает по-настоящему — пересечения выходят арифметикой, приоритет классов работает по-настоящему —
[классы расхождений и опоздание](fate.md). [классы расхождений и опоздание](fate.md).
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов - **Паспорт хранит только то, чего движение мира не меняет**: хеш байтов
каждого слепка и счётчики наблюдаемых классов — каждого слепка и счётчики наблюдаемых классов — [что хранит
[что хранит опись](inventory.md). паспорт](inventory.md).
- **Мост к склейке**: план состава владеет человеком; его непрозрачный - **Мост к склейке**: план состава владеет человеком; его непрозрачный
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным — `person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
[мост к склейке](identity.md). [мост к склейке](identity.md).
+20 -21
View File
@@ -8,18 +8,18 @@
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа, **Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
решённая при его рождении и уложенная в окно K целиком. решённая при его рождении и уложенная в окно K целиком.
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, **Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, дельта,
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и опоздание. Событийная сторона — порча событийного потока: потеря и дубль. Довод
дубль. Довод за разделение — различимость по описи: правка заказной механики за разделение — различимость по паспорту: правка заказной механики не двигает
не двигает хеши событий, правка событийной не двигает байты слепка, и по хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES` видно, какую сторону трогали. Прежние имена `DISCREPANCIES` и `LATECOMERS`
и `LATECOMERS` решения не переживают — они названы по классам витрины, а решения не переживают — они названы по классам витрины, а компонент называет
компонент называет часть мира; новые стороны занимают те же позиции. часть мира; новые стороны занимают те же позиции. Заказная сторона названа при
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2; реализации — подпоток `ORDERS` на позиции 2; событийная — `BEACONS` на позиции
событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо 3: маячок счетчика либо не долетает, либо отправляется повторно. Отклонено:
отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка *один компонент на всю судьбу* — правка событийной механики молча меняла бы
событийной механики молча меняла бы байты слепка; *компонент на класс* — байты слепка; *компонент на класс* — пять имён под ручки калибровки, которые
пять имён под ручки калибровки, которые крутятся разом. крутятся разом.
**Правило формы, без которого разделение не работает: броски заказной стороны **Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
@@ -62,15 +62,14 @@
процентов» не заводим. процентов» не заводим.
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя **Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии версия; различает их история версий: строки STG и физические версии `ods.order`
`ods.order` до фоновых слияний, а в витринах — выручка дня, которая до фоновых слияний, а в витринах — выручка дня, которая сначала выросла, потом
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние убыла. Полное различение не обещано: слепок — состояние на границе суток, и
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у оплата с отменой в один день в сырых данных неразличимы; то же у сильно
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока опоздавших, приехавших уже терминальными. Точную форму этого урока решает этап
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда деться, кроме
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* — как остаться брошенным; *мгновенная отмена при рождении* — «дыхание» окна на
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1, отменах исчезает; *отмен нет вовсе* — страховочный срез 1, он в резерве.
он в резерве.
## Классы расхождений и опоздание ## Классы расхождений и опоздание
+4 -4
View File
@@ -24,10 +24,10 @@
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`; `ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
отдельные прогоны одного мира дают то же соответствие; принятый мир отдельные прогоны одного мира дают то же соответствие; принятый мир
воспроизводит эффект склейки без случайных ложных объединений — конкретные воспроизводит эффект склейки без случайных ложных объединений. Паспорт не
числа пар контрактом описи не являются. Межкогортные столкновения принимаются закрепляет конкретное число пар. Межкогортные столкновения принимаются по той же
по той же дисциплине, что у случайных `ClientID`: принимается конкретный дисциплине, что у случайных `ClientID`: принимается конкретный канонический мир
канонический мир по внешнему результату. по внешнему результату.
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* — пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
+12 -11
View File
@@ -35,10 +35,11 @@ PARTITION` могла бы заменить партицию дня неполн
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт запускает его шаговый даг модельного времени, который положил слепок в топик и
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow. ждёт конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта. `_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` как параметр `SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя. ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
@@ -157,7 +158,7 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде. собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он Позиция мира при этом не двигается, и тот же день уедет заново — в STG он
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»). ([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
@@ -191,10 +192,10 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
## Что проверено ## Что проверено
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94. Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение сырья на годные Опыты этого раздела прогнаны и подтвердили обещанное: разбиение строк STG на
строки и брак полное и непересекающееся; две версии одного заказа легли в одну годные строки и брак полное и непересекающееся; две версии одного заказа легли в
партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от фонового одну партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от
слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её фонового слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки `_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
опирается, — [документ хранилища](../storage.md), «Что проверено». опирается, — [документ хранилища](../storage.md), «Что проверено».
@@ -217,9 +218,9 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
исполнении #165. После двух слепков физическая таблица содержала 3356 версий исполнении #165. После двух слепков физическая таблица содержала 3356 версий
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним 1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним слепком
слепком заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из сырых
сырья. В обеих сверках расхождений нет. данных. В обеих сверках расхождений нет.
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны, #93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
+12 -12
View File
@@ -1,20 +1,20 @@
# Что хранит опись # Что хранит паспорт мира
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72) обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
и [«Места заказов в стартовом и [«Места заказов в стартовом
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74). мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
Словарь описи — растяжка-хеш против опоры-счётчика — задан «Чем меряется Термины паспорта — «растяжка-хеш» и «опора-счётчик» — заданы разделом «Чем
генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и меряется генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
термином «Опись мира» в [CONTEXT.md](../../../CONTEXT.md). статьёй «Паспорт мира» в [CONTEXT.md](../../../CONTEXT.md).
> Опись хранит только то, чего движение мира не меняет. > Паспорт хранит только то, чего движение мира не меняет. Хеш кладём всегда,
> Хеш кладём всегда, счётчик — только когда назван его читатель. > счётчик — только когда назван его читатель.
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты - **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же байты»)
байты») опись начинает сторожить. паспорт начинает сторожить.
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по - **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит
число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных
@@ -24,7 +24,7 @@
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных - **Контрольные числа идентичности не заводятся вовсе**: uniq известных
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
не выходит; генератор сторожит хеш, транспорт — счёт событий. не выходит; генератор сторожит хеш, транспорт — счёт событий.
- **Опись описывает мир, а не доставку.** Работа генератора кончается на - **Паспорт описывает мир, а не доставку.** Работа генератора кончается на
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его Kafka: дошли ли байты до `ods.order` — вопрос стенда и его проверок. Поэтому
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка счёта строк у слепка в паспорте нет; понадобится проверка приёма заказов —
приёма заказов — число заведётся вместе с ней. число заведётся вместе с ней.
+2 -2
View File
@@ -112,5 +112,5 @@
такого значения означает ошибку арифметики источника. такого значения означает ошибку арифметики источника.
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка — Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт его
его даром, а хешу слепка в описи нужен именно названный порядок. даром, а хешу слепка в паспорте нужен именно названный порядок.
+8 -8
View File
@@ -25,11 +25,11 @@
Цена принята с открытыми глазами: у части пар стартового мира поздний Цена принята с открытыми глазами: у части пар стартового мира поздний
назначенный заказ падает на день 7, и до первого хода мира этих пар в назначенный заказ падает на день 7, и до первого хода мира этих пар в
`dds.identity_map` нет. Опись пар не считает `dds.identity_map` нет. Паспорт пар не считает ([что хранит
([что хранит опись](inventory.md)), поэтому красной проверки из этого не паспорт](inventory.md)), поэтому красной проверки из этого не выходит.
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на свежем
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем ровно
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* — там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* — число
число верно ровно до первого хода мира; *отменить сдвиг отправки* — верно ровно до первого хода мира; *отменить сдвиг отправки* — `awaiting_order`
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир пропал бы навсегда; *подогнать план под горизонт* — мир перестал бы быть чистой
перестал бы быть чистой функцией зерна. функцией зерна.
+36 -35
View File
@@ -17,27 +17,28 @@
`airflow` и `superset`; `airflow` и `superset`;
- `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` — - `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` —
Airflow 3.3 с LocalExecutor, без triggerer; Airflow 3.3 с LocalExecutor, без triggerer;
- `superset` — интерфейс и подготовленное подключение ClickHouse; - `superset` — интерфейс с заранее настроенным подключением к ClickHouse;
- `prometheus` и `grafana` — сбор и просмотр метрик ClickHouse и Kafka. - `prometheus` и `grafana` — сбор и просмотр метрик ClickHouse и Kafka.
## Пользователи и роли ClickHouse ## Пользователи и роли ClickHouse
В ClickHouse семь пользователей и шесть ролей: В ClickHouse семь пользователей и шесть ролей:
- `etl` подключает рабочие даги Airflow; роль `etl_writer` читает и пишет слои - `etl` — учётная запись рабочих дагов Airflow; роль `etl_writer` читает и
хранилища; пишет слои хранилища;
- `lifecycle` применяет канонический DDL и пересоздаёт прикладные базы; роль - `lifecycle` — учётная запись дагов жизненного цикла; роль `lifecycle_owner`
`lifecycle_owner` не даёт эти административные права рабочим дагам; разрешает им применять канонический DDL и пересоздавать прикладные базы. У
- `bi` подключает Superset; роль `bi_reader` читает ODS, DDS, DM и справочники рабочих дагов этих административных прав нет;
`dic`, а запросы Superset обращаются к публичным `_v`; - `bi` — учётная запись Superset; роль `bi_reader` читает ODS, DDS, DM и
- `analyst` предназначен для подключения человека и читает все слои и справочники `dic`, а запросы Superset обращаются к публичным `_v`;
справочники; - `analyst` — учётная запись аналитика с правом чтения всех слоёв и
- `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои, справочников;
- `grafana` — учётная запись Grafana; роль `monitoring_reader` читает все слои,
системные таблицы и данные со всех реплик кластера; системные таблицы и данные со всех реплик кластера;
- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит - `dict` — беспарольная учётная запись словаря товаров с правом чтения базы
за своей подложкой; `dic`; словарь читает через неё свою подложку;
- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри - `default` — служебная учётная запись для проверок состояния и скриптов внутри
контейнеров, но не приложения. контейнеров; приложения с ней не подключаются.
Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в
[ADR 0007](../adr/0007-clickhouse-access.md). [ADR 0007](../adr/0007-clickhouse-access.md).
@@ -48,9 +49,8 @@ Superset получает пароль `bi`, а Grafana — пароль `grafan
## Применение настроек ## Применение настроек
Изменения значений ClickHouse в `.env` и файлов настройки серверов в Чтобы применить новые значения ClickHouse из `.env` или изменения файлов в
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/` применяются `infra/clickhouse/config.d/` и `infra/clickhouse/users.d/`, пересоздайте ноды:
пересозданием нод:
```bash ```bash
docker compose up --force-recreate --wait clickhouse-01 clickhouse-02 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` в рабочую среду. Все защиты: не копируйте значения из `.env.example` в рабочую среду. Все
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
опубликован. опубликован.
@@ -84,20 +83,22 @@ ClickHouse:
[ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication), [ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication),
[ON CLUSTER](https://clickhouse.com/docs/sql-reference/distributed-ddl) и [ON CLUSTER](https://clickhouse.com/docs/sql-reference/distributed-ddl) и
[Distributed](https://clickhouse.com/docs/engines/table-engines/special/distributed). [Distributed](https://clickhouse.com/docs/engines/table-engines/special/distributed).
Описание кластера задаётся через `remote_servers`, макросы — через `macros`, Кластер описан в `remote_servers`, макросы — в `macros`, а подключение к
подключение к keeper — через `zookeeper`; путь `ReplicatedMergeTree` содержит keeper — в `zookeeper`. Путь `ReplicatedMergeTree` содержит `{shard}` и
`{shard}` и `{replica}`, а `Distributed` получает имя кластера, базу, локальную `{replica}`.
таблицу и ключ шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER` Для `Distributed` задаются имя кластера, база, локальная таблица и ключ
создавал отдельный путь каждого шарда без вписанных вручную значений. Для шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER` создавал
образа зафиксирован точный текущий отдельный путь для каждого шарда без вписанных вручную значений. В образе
[LTS-выпуск 26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts); закреплена точная версия текущего LTS-выпуска —
[26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts);
серверы и keeper используют один образ. серверы и keeper используют один образ.
Настройки Kafka 4.3.1 сверены с Настройки 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). [примером односерверного KRaft](https://github.com/apache/kafka/blob/4.3.1/docker/examples/docker-compose-files/single-node/plaintext/docker-compose.yml).
Секция метрик взята из конфигурации закреплённого образа ClickHouse и проверена Раздел метрик взят из конфигурации закреплённого образа ClickHouse и проверен
на серверах и keeper. Подготовка источников Grafana сверена с на серверах и keeper. Автоматическая настройка источников данных Grafana
сверена с
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/) [официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/)
и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/). и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/).
Prometheus собирает встроенные метрики двух серверов и keeper, а через Prometheus собирает встроенные метрики двух серверов и keeper, а через
@@ -109,18 +110,18 @@ Prometheus собирает встроенные метрики двух сер
пока нет. пока нет.
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor, 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), [архитектурой 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/public-airflow-interface.html)
и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html). и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html).
Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет
общий `SQLExecuteQueryOperator`, программные запросы идут через общий `SQLExecuteQueryOperator`, программные запросы идут через
`ClickHouseHook`. Провайдер работает поверх закреплённого `ClickHouseHook`. Провайдер работает поверх закреплённого
`clickhouse-connect`. Официальный провайдер Kafka сам использует `clickhouse-connect`. Официальный провайдер Kafka уже использует
`confluent-kafka`; отдельное подключение и его обёртки здесь не нужны, поэтому `confluent-kafka`. В стенде клиент добавлен в образ напрямую, без отдельного
клиент Kafka добавлен в образ напрямую. подключения Airflow и обёрток провайдера.
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, схема адреса
`clickhousedb://` и драйвер Postgres сверены с подключения `clickhousedb://` и драйвер Postgres сверены с
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/) [документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
и [настройкой базы метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/). и [настройкой базы метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/).
+159 -161
View File
@@ -7,20 +7,19 @@
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов, **Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное `Kafka → STG → ODS` — чтец топика `hits`, таблицы STG, типизированное событие с
событие с таблицей ошибок, поверхность актуального состояния и три матвью. таблицей ошибок, поверхность актуального состояния и три матвью. Этап 3 добавил
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой вход второго источника и довёл его до ODS: топик `orders`, свой чтец, свой сырой
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего слой, версии заказов с таблицей ошибок и поверхность текущего состояния.
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. Тот же этап
Тот же этап принёс первый справочник — словарь товаров из общего с генератором принёс первый справочник — словарь товаров из общего с генератором CSV-файла.
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а подложку над ним
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат публичным договором
публичным договором события `dds.event_v` над точным состоянием ODS, а следом события `dds.event_v` над точным состоянием ODS, а следом получил две физические
получил две физические модели слоя — заказ и сессию: их собирает заменой модели слоя — заказ и сессию: их собирает заменой партиций даг `dds_transform`.
партиций даг `dds_transform`. Сессия среди них особая: в потоке такой сущности Сессия среди них особая: в потоке такой сущности нет, стенд режет её из событий
нет, стенд режет её из событий сам. сам. Дальше по тексту устройство описано так, как оно проектируется; построенное
Дальше по тексту устройство описано так, как оно проектируется; построенное от от заложенного отличает карта таблиц в конце.
заложенного отличает карта таблиц в конце.
Зона ответственности у документа одна — хранилище. Генератор описан отдельно: Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
@@ -97,7 +96,7 @@ ClickHouse и отклонённые варианты записаны в
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
выбран против перекоса: структурированный числовой идентификатор распределяется выбран против перекоса: структурированный числовой идентификатор распределяется
по остатку от деления неравномерно. У сырья выбора нет — строку иначе не по остатку от деления неравномерно. У сырого слоя выбора нет — строку иначе не
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
шард. шард.
@@ -157,10 +156,10 @@ FROM источник_dist`. Заводское значение `parallel_distr
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена `_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера `Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо
либо падение приёма на первом сообщении, либо тихие нули за 1970 год. падение приёма на первом сообщении, либо тихие нули за 1970 год.
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
@@ -173,10 +172,10 @@ FROM источник_dist`. Заводское значение `parallel_distr
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
0010](../adr/0010-order-versions-in-ods.md)). 0010](../adr/0010-order-versions-in-ods.md)).
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition, Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство
которого он заведён: повтор доставки в сырье обязан быть виден. слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
@@ -192,7 +191,7 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью. `_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
Airflow. Переделать разобранное руками можно вставкой из сырья с фильтром по Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу `_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
`_load_id`; дневные партиции ODS он не заменяет. `_load_id`; дневные партиции ODS он не заменяет.
@@ -223,10 +222,10 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` — Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
исключение: источник объявляет `Array(Float64)`, а материализованное исключение: источник объявляет `Array(Float64)`, а материализованное
представление ODS разбирает числа сырого JSON сразу в представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`.
`Array(Decimal(18, 2))`. DDS наследует этот тип без нового преобразования. DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден
Прямой разбор подтвержден документацией ClickHouse через Context7 и запросом документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
к ClickHouse 26.3; сравнение с сырьем подтвердило: копейки всех покупок целы. с сырыми данными подтвердило: копейки всех покупок целы.
## Версии заказов ## Версии заказов
@@ -257,16 +256,16 @@ DDS. `snapshot_date` в нём одновременно дата наблюде
**Линза называется явно — в типе колонки либо в вызове функции.** Третий **Линза называется явно — в типе колонки либо в вызове функции.** Третий
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там, источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
где линза что-то решает — в объявлении хранимой колонки и в выражении, где линза что-то решает — в объявлении хранимой колонки и в выражении, считающем
считающем дату, — его не остаётся. Прописать этот пояс своей рукой — дату, — его не остаётся. Прописать этот пояс своей рукой — `<timezone>` в
`<timezone>` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью конфигурации ноды или `TZ` контейнеру — было бы той же болезнью с другим
с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в
и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться, разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
поменяв его. Правило стоит на источнике пояса, а не на функции: поменяв его. Правило стоит на источнике пояса, а не на функции: `toDate` по
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и колонке, чей тип пояс несёт, законен и имени не требует — так и работают ключи
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя партиций `toDate(_load_ts)` у таблицы STG и у таблицы ошибок. Имя пишется тогда,
пишется тогда, когда нужна другая линза, чем у колонки: когда нужна другая линза, чем у колонки: `toDate(UTCEventTime, 'Europe/Samara')`
`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика». — это «день по часам счётчика».
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок **Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна, выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
@@ -314,8 +313,8 @@ UTC+4), и пересчёт идёт один раз при наполнении
0008](../adr/0008-order-ingestion.md) и [спецификация приёма 0008](../adr/0008-order-ingestion.md) и [спецификация приёма
заказов](orders/ingestion.md). заказов](orders/ingestion.md).
Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица
таблица ошибок ODS. ошибок ODS.
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт `clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
@@ -344,15 +343,15 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
как при фоновом первая нода продолжает принимать и копит спул для соседа. как при фоновом первая нода продолжает принимать и копит спул для соседа.
Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё, Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой. компромисс полезнее показать, чем спрятать за галочкой.
Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым
запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято значением и запись-надгробие проходят молча, не оставляя строки. Свойство
осознанно — подробности в разделе «Что проверено». измерено и принято осознанно — подробности в разделе «Что проверено».
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано **Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
@@ -361,14 +360,14 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны. один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня `ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения. модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
сырому слою не относится. конвейера к сырому слою не относится.
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`). тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
Удвоение сырья проходит мимо неё только потому, что при повторном чтении Удвоение строк STG проходит мимо неё только потому, что при повторном чтении
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на `_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
отсутствии механизма. отсутствии механизма.
@@ -384,53 +383,54 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
роняет вставку и останавливает потребление до починки роняет вставку и останавливает потребление до починки
([ADR 0005](../adr/0005-event-ingestion.md)). ([ADR 0005](../adr/0005-event-ingestion.md)).
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырья Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого
срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события + слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG =
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило,
смотреть на оповещения красное в норме, учит не смотреть на оповещения ([ADR
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
опытом при исполнении #43, на управляемой пачке: отправили N сообщений — исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
опыт не становится, и почему — в [карте почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
проверок](testing.md), раздел «Интеграционная проверка постоянной целью не постоянной целью не становится». Счёт актуальных событий идёт через
становится». Счёт актуальных событий идёт через `ods.event_v`, которое скрывает `ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
`FINAL`: голый `count()` по `ReplacingMergeTree` зависит от того, сколько зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
мержей успело пройти, и спека это прямо запрещает (раздел 6). (раздел 6).
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS **Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
против [описи мира](../../data/world-inventory.json), лежащей в git против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make
(`make check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся
этом вся разница: опись описывает восемь дней стартового мира, счёт обрамлён разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами,
их датами, повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
помеха — ODS хранит всё. Обе стороны равенства зафиксированы: одна кодом хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая
генератора, другая файлом в git. У межслойной сверки такой опоры нет ни с файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
одной стороны.
## Срок жизни сырья ## Срок жизни сырых данных
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты, окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками
значение проставлено явно, чтобы поведение не зависело от умолчания версии. (`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не
зависело от умолчания версии.
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица Нарезать сырые данные по модельному дню события было бы соблазнительно — он
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
снимается целиком, только когда в нём истекли все строки, а в партиции модельного снимается целиком, только когда в нём истекли все строки, а в партиции
дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста, модельного дня лежит приехавшее в разное реальное время: мерж склеит куски
самая свежая строка удержит весь кусок, и данные переживут срок неограниченно. разного возраста, самая свежая строка удержит весь кусок, и данные переживут
срок неограниченно.
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
возраста — но не более чем на сутки, потому что партицию закрывает календарный возраста — но не более чем на сутки, потому что партицию закрывает календарный
день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не
трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику,
«уходит само» здесь обещано, а «уходит вовремя» — нет. так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые
режется своими координатами: и разбор полётов, и переобработка фильтруют по данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют
`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается. модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
@@ -440,21 +440,21 @@ D0 и к реальному календарю не привязана; паке
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
эталонные дни прямо на входе. эталонные дни прямо на входе.
В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе. В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
ноль. а не ноль.
## Таблица ошибок ## Таблица ошибок
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым `ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у текстом, метаданными доставки и классом брака. Ключи её собственные, потому что
брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у у брака нет разобранных полей: шардируется `cityHash64` сырой строки —
строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и `ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню
сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним, загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
разбираться к моменту разбирательства будет уже нечем. Снятие — целыми кусками брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
(`ttl_only_drop_parts`), как у сырья и по той же причине: строки в партиции дня нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
загрузки разного возраста не более чем на сутки, и куску незачем переживать причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
срок из-за самой свежей строки. и куску незачем переживать срок из-за самой свежей строки.
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
@@ -500,13 +500,12 @@ kafka_offset)`: смотрят такую таблицу от класса, а
## Раскладка SQL ## Раскладка SQL
Исполняемые дагами запросы лежат по правилу Исполняемые дагами запросы лежат по правилу `sql/<слой>/<объект>_<роль>.sql`.
`sql/<слой>/<объект>_<роль>.sql`. Слой — слой цели запроса: забор заказов в Слой — слой цели запроса: забор заказов в STG живёт в
сырьё живёт в `sql/stg/orders_raw_load.sql`, разбор — в `sql/stg/orders_raw_load.sql`, разбор — в `sql/ods/order_parse.sql`. Даг
`sql/ods/order_parse.sql`. Даг называет файлы, передаёт параметры и задаёт называет файлы, передаёт параметры и задаёт порядок выполнения, но не хранит
порядок выполнения, но не хранит текст преобразований. Два выражения разбора текст преобразований. Два выражения разбора делят локальный макрос Jinja в том
делят локальный макрос Jinja в том же файле; подключаемого SQL-фрагмента между же файле; подключаемого SQL-фрагмента между ними нет.
ними нет.
SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода
обработчик DAG читает список DDL-файлов, загружает содержимое каждого обработчик DAG читает список DDL-файлов, загружает содержимое каждого
@@ -524,10 +523,10 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|---|---| |---|---|
| `00-databases.sql` | базы слоёв и зона справочников | | `00-databases.sql` | базы слоёв и зона справочников |
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё | | `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников | | `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы STG обоих источников |
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок | | `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS | | `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
| `40-stg-views.sql` | матвью приёма: чтец в сырьё | | `40-stg-views.sql` | матвью приёма: чтец в STG |
| `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций | | `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
| `50-dds-views.sql` | публичные договоры сущностей DDS | | `50-dds-views.sql` | публичные договоры сущностей DDS |
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций | | `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
@@ -537,14 +536,14 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
правила порядка слоёв к ней не применяются. правила порядка слоёв к ней не применяются.
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся матвью
матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает читать
читать топик, когда к нему привязывают первую матвью; создай её раньше топик, когда к нему привязывают первую матвью; создай её раньше разбора — и всё,
разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS что доедет в зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в
никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден, ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет.
поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на
снесены, а данные Kafka целы, — то есть на обычной отладке. обычной отладке.
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
`lifecycle`: один файл — одна последовательная задача `lifecycle`: один файл — одна последовательная задача
@@ -579,13 +578,13 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|---|---|---| |---|---|---|
| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` | | `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
| `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки | | `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_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью |
| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` | | `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие | | `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие |
| `ods` | `ods.event_v` | актуальная версия события с полями источника | | `ods` | `ods.event_v` | актуальная версия события с полями источника |
| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём | | `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_rep` / `_dist` | типизированные версии заказа |
| `ods` | `ods.order_v` | текущая версия заказа на языке источника | | `ods` | `ods.order_v` | текущая версия заказа на языке источника |
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | | `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
@@ -612,9 +611,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором | | `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
| `dic` | `dic.products` | словарь товаров поверх подложки | | `dic` | `dic.products` | словарь товаров поверх подложки |
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два Матвью разбора у заказов нет: срез STG раскладывают по этим двум целям два
`INSERT SELECT` одной задачи `parse_batch` из файла `INSERT SELECT` одной задачи `parse_batch` из файла `sql/ods/order_parse.sql`.
`sql/ods/order_parse.sql`.
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции; остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
@@ -655,11 +653,11 @@ DDL-словарь с файловым источником внутри `user_f
**Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и **Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и
`system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку `system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку
значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT` значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT`
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. На
На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов лежали на
лежали на обоих шардах, после явного нуля и повторного разбора того же обоих шардах, после явного нуля и повторного разбора того же `_load_id` — ни
`_load_id` — ни одного. Вершина версий уцелела: деловые поля `ods.order_v` одного. Вершина версий уцелела: деловые поля `ods.order_v` сошлись с эталоном,
сошлись с эталоном, посчитанным прямо из сырья. посчитанным прямо из STG.
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил, **Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
@@ -683,19 +681,19 @@ DDL-словарь с файловым источником внутри `user_f
потолком разбор отдаёт как `NULL`. Это ловит опыт, который берёт «заведомо потолком разбор отдаёт как `NULL`. Это ловит опыт, который берёт «заведомо
далёкий» год: 2999-й не разбирается вовсе. далёкий» год: 2999-й не разбирается вовсе.
Про сам приём заказов снято следующее. Предикат формы провода разложил все Про сам приём заказов снято следующее. Предикат формы провода разложил все 13
13 661 строку сырья, приехавшую при исполнении #93, без исключений и без 661 строку STG, приехавшую при исполнении #93, без исключений и без `NULL`: 13
`NULL`: 13 653 годных и 8 брака, сумма сошлась с общим счётом по каждому 653 годных и 8 брака, сумма сошлась с общим счётом по каждому `_load_id`,
`_load_id`, пересечение ветвей — ноль. Все восемь строк брака — версии одного пересечение ветвей — ноль. Все восемь строк брака — версии одного заказа с
заказа с отрицательным `total`; это дефект генератора, разобранный в отрицательным `total`; это дефект генератора, разобранный в [спецификации
[спецификации приёма](orders/ingestion.md), «Что проверено». На управляемой приёма](orders/ingestion.md), «Что проверено». На управляемой порции из пяти
порции из пяти строк (две версии одного заказа плюс по одной строке каждого строк (две версии одного заказа плюс по одной строке каждого класса брака)
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с годные ушли в `ods.order`, брак — в `ods.order_errors` с ожидаемыми классами.
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL` Обе версии легли в одну партицию и на один шард; `FINAL` через распределённую
через распределённую таблицу вернул одну строку — ту, что несла позднейшую таблицу вернул одну строку — ту, что несла позднейшую версию, — тогда как
версию, — тогда как физический счёт показывал две. Повтор задачи с тем же физический счёт показывал две. Повтор задачи с тем же `_load_id` строку в
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок записала тот же брак
записала тот же брак второй раз, как и обещано спецификацией приёма. второй раз, как и обещано спецификацией приёма.
Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у
запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, — запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, —
@@ -758,27 +756,26 @@ DDL-словарь с файловым источником внутри `user_f
вовсе, до 7 августа утверждение держалось на опыте владельца. вовсе, до 7 августа утверждение держалось на опыте владельца.
- Упавшая матвью роняет вставку и останавливает потребление до починки. - Упавшая матвью роняет вставку и останавливает потребление до починки.
Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за
двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, а двадцать секунд (сброс блока идёт за 7,5) в STG не приехало ничего, а офсет
офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение группы застыл с отставанием в одно сообщение. Цель вернули — сообщение доехало
доехало само, без повторной отправки, отставание ушло в ноль, событие само, без повторной отправки, отставание ушло в ноль, событие разобралось.
разобралось. Сносить надо именно распределённую таблицу: вставка в Сносить надо именно распределённую таблицу: вставка в `Distributed` кладёт
`Distributed` кладёт блок в спул и сразу возвращает управление, так что на блок в спул и сразу возвращает управление, так что на сносе локальной ошибка
сносе локальной ошибка всплыла бы фоном и утверждение показалось бы всплыла бы фоном и утверждение показалось бы опровергнутым.
опровергнутым. - `_load_ts` в `ods.event` — это метка исходной строки STG, а не время
- `_load_ts` в `ods.event` — это метка исходной строки сырья, а не время
разбора. Сверено по `WatchID` на двух тысячах событий модельного дня, разбора. Сверено по `WatchID` на двух тысячах событий модельного дня,
залитого дважды: у каждого события метка совпала с меткой одной из двух его залитого дважды: у каждого события метка совпала с меткой одной из двух его
доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди
доставок» ноль, то есть `now64()` в матвью разбора нет. доставок» ноль, то есть `now64()` в матвью разбора нет.
- Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново - Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу созданные матвью разбора при живом чтеце: сообщение, отправленное сразу после,
после, легло в сырьё и не попало в ODS никуда — ни в событие, ни в ошибки; легло в STG и не попало в ODS никуда — ни в событие, ни в ошибки; то же
то же сообщение через минуту разобралось штатно. Воспроизведено дважды сообщение через минуту разобралось штатно. Воспроизведено дважды 7 августа
7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация 2026 года. Это тот же зазор, о котором предупреждает нумерация файлов DDL,
файлов DDL, только приходит он с другой стороны — не при первом создании, а только приходит он с другой стороны — не при первом создании, а при замене
при замене матвью на работающем стенде. Практический вывод один: правишь матвью на работающем стенде. Практический вывод один: правишь матвью — не верь
матвью — не верь ближайшей отправке, повтори её. Чем именно держится ближайшей отправке, повтори её. Чем именно держится задержка, не измерено;
задержка, не измерено; наблюдение записано как наблюдение. наблюдение записано как наблюдение.
- Форма ключа `ods.event` принимается такой, как её задумала спека: выражение - Форма ключа `ods.event` принимается такой, как её задумала спека: выражение
`intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а `intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а
`SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11 `SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11
@@ -811,18 +808,19 @@ DDL-словарь с файловым источником внутри `user_f
Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись
Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`) Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`)
читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в читаются, двигают офсет потребителя и не дают строки вовсе — ни в STG, ни в
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа таблицу ошибок; приём при этом не останавливается. Проверено 5 августа 2026
2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — в STG
в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при чём:
чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на том
том же наборе даёт ровно те же две строки и тот же офсет. же наборе даёт ровно те же две строки и тот же офсет.
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он. самой таблице STG, `kafka_offset` лежит там колонкой, и дырка в офсетах — это
он.
Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно <локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
@@ -865,7 +863,7 @@ Kafka с пустым значением (ноль байт) и запись-н
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив - **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается `purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
ClickHouse 26.3; все суммы после ODS совпали с сырьем до копейки. ClickHouse 26.3; все суммы после ODS совпали с сырыми данными до копейки.
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие **Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
матвью разбора, закрыты опытами при исполнении #43 и переехали выше. матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
+42 -43
View File
@@ -23,9 +23,9 @@
заметит потерянного подключения Superset: он про ClickHouse и только. заметит потерянного подключения Superset: он про ClickHouse и только.
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
против описи — вопрос к ClickHouse: строки в `ods.event` считает сам против паспорта — вопрос к ClickHouse: строки в `ods.event` считает сам сервер и
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене они
они подошли бы смоуку. подошли бы смоуку.
## Карта целей ## Карта целей
@@ -44,11 +44,11 @@
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с | | `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с | | `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с | | `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с | | `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против паспорта | нужен | 24 с |
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с | | `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с | | `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с | | `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с | | `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [паспорт мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за **Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
разными дверями охватывают разное: корневая не видит ни одного файла разными дверями охватывают разное: корневая не видит ни одного файла
@@ -87,7 +87,7 @@ Airflow. В цену целей она не входит. На готовом м
`make rebuild-storage` тоже не проверка. Команда через Airflow удаляет `make rebuild-storage` тоже не проверка. Команда через Airflow удаляет
прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL, прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL,
заливает стартовый мир и возвращает его пульт к концу стартовых дней. Службы и заливает стартовый мир и возвращает его позицию к концу стартовых дней. Службы и
их тома она сохраняет, поэтому возврат к каноническим данным не требует их тома она сохраняет, поэтому возврат к каноническим данным не требует
холодного запуска всего стенда. холодного запуска всего стенда.
@@ -131,10 +131,9 @@ ClickHouse отвечает сразу.
дорогая. Правки дагов и Superset — корневой `make lint`. дорогая. Правки дагов и Superset — корневой `make lint`.
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы для сверки с паспортом мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
узнать, тот ли получается мир, его надо получить. Место выбрано по той же узнать, тот ли получается мир, его надо получить. Место выбрано по той же оси
оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
нужен, — и
краснеет проверка там, где надо: сразу после правки генератора. Не будь её, краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
поломкой хранилища, а не забытой пересборкой. поломкой хранилища, а не забытой пересборкой.
@@ -172,10 +171,10 @@ ClickHouse отвечает сразу.
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
становится темой урока. становится темой урока.
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырья срок Довод не про цену прогона, а про данные. Вложенное остаётся: у сырых данных срок
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и их.
их. Хуже явного мусора: явный видно. Хуже явного мусора: явный видно.
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.** Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
Дешевле всего это выходит партицией — опыт играет модельный день за границей Дешевле всего это выходит партицией — опыт играет модельный день за границей
@@ -184,10 +183,10 @@ ClickHouse отвечает сразу.
же механизм, на котором стоит переобработка дня X. же механизм, на котором стоит переобработка дня X.
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех, Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
что стенд произвёл сам: подневный счёт событий против описи мира ловит и что стенд произвёл сам: подневный счёт событий против паспорта мира ловит и
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге, сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки ничего при этом не вкладывая. Это единственный постоянный сторож цепочки Kafka →
Kafka → STG → ODS, и живёт он в `make check-clickhouse`. STG → ODS, и живёт он в `make check-clickhouse`.
Правило, которое такая проверка обязана выдержать и которое стоит держать в Правило, которое такая проверка обязана выдержать и которое стоит держать в
голове для следующей: **на растущих данных утверждать можно только про голове для следующей: **на растущих данных утверждать можно только про
@@ -281,9 +280,9 @@ Kafka по той же причине спрашивают снаружи. Её
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse: `make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`, описание кластера, макросы, связь с 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 time make test
``` ```
Опись пересобралась за 20,515 с. Файловый приемник записал 460 731 812 байт: Паспорт пересобрался за 20,515 с. Файловый приемник записал 460 731 812 байт:
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0 458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0 за
за 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов стало
стало 437 против 407, и потяжелел сам мир — та же пересборка восьми дней 437 против 407, и потяжелел сам мир — та же пересборка восьми дней стоила 13 с,
стоила 13 с, теперь 20,5. Эта правка добавила один тест — проигрыш дня. теперь 20,5. Эта правка добавила один тест — проигрыш дня.
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал **Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел 28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
@@ -464,11 +463,11 @@ shell-версия узкой пересборки за 2 м 25 с пересо
`make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за `make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за
3 м 35 с. Отправка семи слепков заняла около 28 секунд. 3 м 35 с. Отправка семи слепков заняла около 28 секунд.
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и дагов
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время модельного времени. Весь рост приписывать слепкам нельзя: на холодном прогоне
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й основное время ушло ещё до сценариев ожидания: `airflow-init` завершился
секунде, обработчик DAG стал здоров примерно на 458-й. Числа здесь называют примерно на 320-й секунде, обработчик DAG стал здоров примерно на 458-й. Числа
цену нынешнего стенда, а не выносят вердикт о регрессии. здесь называют цену нынешнего стенда, а не выносят вердикт о регрессии.
**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой **Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
@@ -484,25 +483,25 @@ shell-версия узкой пересборки за 2 м 25 с пересо
от файла, и ближний выигрывает. от файла, и ближний выигрывает.
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля **Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка: (`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка: 18,7
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka. с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka. Повторный
Повторный `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это не оплошность
не оплошность — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`. — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`. Смоук — 9 с,
Смоук — 9 с, `check-clickhouse` — 8 с с новой девятой проверкой. `make test` `check-clickhouse` — 8 с с новой девятой проверкой. `make test` вырос до 71 с:
вырос до 71 с: 58 с прежних тестов плюс 13 с на пересборку восьми дней для 58 с прежних тестов плюс 13 с на пересборку восьми дней для сверки с паспортом.
сверки с описью. Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
#41 и #43. #41 и #43.
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала `ods.event_rep` — проверка покраснела, показала недостающий день и назвала
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца топика».
топика». Затем положили в сырьё заведомо негодную строку — брак появился, и Затем положили в STG заведомо негодную строку — брак появился, и диагноз
диагноз сменился на второй, с признаком для различения: «сойдётся недостача с сменился на второй, с признаком для различения: «сойдётся недостача с числом
числом брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки опыта
опыта убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт вернулся к
вернулся к 401 185, и это заодно показало дедупликацию: повтор дня не удвоил 401 185, и это заодно показало дедупликацию: повтор дня не удвоил счёт под
счёт под `FINAL`. `FINAL`.
Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и
совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону: совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону:
+75 -73
View File
@@ -1,12 +1,13 @@
# Мир и его пульт # Мир и модельное время
Модельное время стенда со стороны оператора: как рождается мир, что двигает Оператор управляет модельным временем стенда. Здесь разобрано, как рождается
его ось, кто играет дни и где хранится позиция. мир, что двигает его ось, какие даги играют дни и где хранится позиция. Доводы
Доводы об устройстве пульта — в [ADR 0009](../adr/0009-world-control.md), о об устройстве дагов модельного времени — в [ADR
безопасном повторном подъеме и жизненном цикле мира — в 0009](../adr/0009-world-control.md), о безопасном повторном подъёме и жизненном
[ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день стартового цикле мира — в [ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день
мира неполон и что хранит опись, разобрано в [справочнике стартового стартового мира неполон и что хранит паспорт, разобрано в [справочнике
мира](orders/start-world.md) и [справочнике описи](orders/inventory.md). стартового мира](orders/start-world.md) и [справочнике
паспорта](orders/inventory.md).
## Стартовый мир ## Стартовый мир
@@ -15,7 +16,7 @@
генератора и играет в `hits` первые восемь дней модельного времени — понедельник генератора и играет в `hits` первые восемь дней модельного времени — понедельник
по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в
`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только `orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только
тогда ставит позицию мира в `8`. Так у всякой лабы есть данные, и всегда одни и тогда ставит позицию мира в `8`. Так у каждой лабы есть данные, и всегда одни и
те же. те же.
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
@@ -28,21 +29,21 @@
образ и не заливает данные повторно. Изменённую схему применяют адресно либо образ и не заливает данные повторно. Изменённую схему применяют адресно либо
явной командой `make rebuild-storage`. явной командой `make rebuild-storage`.
## Опись мира ## Паспорт мира
Сам мир в git не хранится — он чистая функция зерна, и держать его в Сам мир в git не хранится: он вычисляется из зерна, поэтому готовая копия была
репозитории значило бы держать там кэш. Вместо него лежит **опись мира**, бы лишь кэшем. Вместо него лежит **паспорт мира**,
[`data/world-inventory.json`](../../data/world-inventory.json): зерно, версия [`data/world-inventory.json`](../../data/world-inventory.json): зерно, версия
генератора, хеш каталога товаров и по строке на каждый день — дата, число генератора, хеш каталога товаров и по строке на каждый день — дата, число
событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики классов
классов расхождений по заказам. Опись отвечает на единственный вопрос: тот ли расхождений по заказам. Паспорт отвечает на единственный вопрос: тот ли это мир,
это мир, что был вчера. что был вчера.
Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что Спрашивают его двое. `make test` в `generator/` сверяет паспорт с тем, что
собирается из кода сегодня: правка генератора меняет мир, и опись надо собирается из кода сегодня: правка генератора меняет мир, и паспорт надо
пересобрать — `make inventory` там же. `make check-clickhouse` в корне сверяет пересобрать — `make inventory` там же. `make check-clickhouse` в корне по дням
с описью то, что доехало до `ods.event`, подневно. Хеш дня можно пересчитать и сверяет паспорт с данными, поступившими в `ods.event`. Хеш дня можно пересчитать
руками — это обычный `sha256sum` файла, который пишет файловый приёмник: и руками — это обычный `sha256sum` файла, который пишет файловый приёмник:
```bash ```bash
uv run --project generator python -m clickstream_generator batch \ 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 ```bash
# день D0 целиком в топик hits # день D0 целиком в топик hits
@@ -80,24 +82,24 @@ make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
| `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком | | `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком |
| `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе | | `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе |
Куда отправлять, обеим целям задаёт сам стенд: внутри сети Compose это брокер Адрес назначения для обеих целей задаёт стенд: внутри сети Compose это брокер
`kafka:9092` и топик `hits`. `kafka:9092` и топик `hits`.
Образ цели не пересобирают: нет образа — Compose соберёт его сам, есть — При обычном запуске образ генератора не пересобирается. Если образа нет, Compose
возьмёт как есть. Пересобрать намеренно, после правки `generator/Dockerfile` соберёт его; если есть, использует готовый. После правки `generator/Dockerfile`
или зависимостей, — отдельной командой: или зависимостей образ пересобирают отдельной командой:
```bash ```bash
docker compose --profile generator build generator docker compose --profile generator build generator
``` ```
Тот же образ несёт заливка стартового мира. Перед такой заливкой Для заливки стартового мира нужен тот же образ. Перед такой заливкой
`world_initialize` сам собирает его из текущего дерева; на готовом мире `world_initialize` собирает его из текущего дерева; на готовом мире повторный
повторный `make up` до сборки не доходит. `make up` до сборки не доходит.
Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и Сборка образа и запуск контейнера разделены намеренно. Если цель запуска сама
цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ пересобирает образ, различие между этими действиями исчезает. Следующий запуск
устарел, видно по собственному прогону — это обратная связь, а не ловушка. покажет, что образ устарел.
Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно
событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня — событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня —
@@ -108,21 +110,21 @@ uv run --project generator python -m clickstream_generator batch \
--day 0 --limit 100 --file tmp/day0.jsonl --day 0 --limit 100 --file tmp/day0.jsonl
``` ```
Несколько дней подряд играет один запуск: `--days 8`. Всё, что принимается За один запуск можно проиграть несколько дней подряд: `--days 8`. Всё, что
флагами, принимается и переменными окружения — так генератор позовёт даг принимается флагами, принимается и переменными окружения — так генератор позовёт
этапа 5; полный список у `--help`. даг этапа 5; полный список у `--help`.
**Значения по умолчанию есть не у всех параметров, и это сделано специально.** **Значения по умолчанию есть не у всех параметров, и это сделано специально.**
Зерно, число дней и темп живого дня описывают модель мира — их генератор знает Зерно, число дней и темп живого дня описывают модель мира — их генератор знает
сам. День на оси, приемник и имя топика описывают стенд, на котором генератор сам. День на оси, приёмник и имя топика относятся к конкретному запуску
запустили: если их не задать, он откажется работать и вернет ненулевой код еще генератора на стенде: если их не задать, он откажется работать и вернёт
до первого события. Поэтому значение дня по умолчанию живет в `Makefile`: день ненулевой код ещё до первого события. Поэтому значение дня по умолчанию живёт в
задает тот, кто запускает, а не тот, кого запускают. `Makefile`: день задаёт тот, кто запускает, а не тот, кого запускают.
## Пульт мира ## Даги модельного времени
Считать дни самому необязательно. В Airflow живёт **пульт мира**: два дага Считать дни самому необязательно. В Airflow живут **даги модельного времени**:
создают его начальное состояние, а три двигают ось дальше. два дага создают начальное состояние мира, а три двигают ось дальше.
| Даг | Что делает | | Даг | Что делает |
| --- | --- | | --- | --- |
@@ -130,33 +132,33 @@ uv run --project generator python -m clickstream_generator batch \
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново | | `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один | | `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут | | `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём | | `world_live` | выключатель: пока снят с паузы, запускает `world_live_day` день за днём |
Работник не только играет день: следом он отправляет слепок заказов за Сыграв день, шаговый даг отправляет слепок заказов и запускает даг
сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший `orders_ingest`. Тот забирает слепок из топика `orders` в `stg`; шаговый даг
слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины ждёт его завершения. Так две половины стенда идут в ногу: упал приём — падает и
стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на шаговый даг, а позиция остаётся на месте ([ADR
месте ([ADR 0008](../adr/0008-order-ingestion.md)). 0008](../adr/0008-order-ingestion.md)).
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон дня. Шаговый даг записывает позицию только после успешного завершения дня:
позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая оборванный прогон позицию не двигает, и следующий запуск играет тот же день
переменная означает, что мир ещё не создавали; `-1` — очистка не закончена, заново. Отсутствующая переменная означает, что мир ещё не создавали; `-1` —
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники очистка не закончена, `0` — очистка закончена, а начальная генерация ещё не
начинают играть только с позиции `8` и больше. завершена. Шаговые даги начинают играть только с позиции `8` и больше.
В форме запуска можно выбрать только число дней. Первый день работник берет из В форме запуска можно выбрать только число дней. Первый день шаговый даг берёт
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options». из `world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую Options». Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и
позицию работник пишет в журнал шага `remember_played`. новую позицию шаговый даг пишет в журнал шага `remember_played`.
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно — Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы встанет на границе модельных суток, доиграв начатый день. Форма дагов времени и
целиком — [ADR 0009](../adr/0009-world-control.md). доводы целиком — [ADR 0009](../adr/0009-world-control.md).
**У этого решения есть цена: планировщику Airflow отдан сокет Docker** — **У этого решения есть цена: планировщик Airflow подключён к сокету Docker.**
иначе ему не поднять контейнер генератора. Доступ к сокету равен правам root Без него планировщик не сможет запустить контейнер генератора. Доступ к сокету
на машине; для локального учебного стенда это приемлемо, но знать об этом равен правам root на машине; для локального учебного стенда это приемлемо, но
нужно. Доступ дает не само монтирование сокета, а членство в группе `docker`: знать об этом нужно. Доступ даёт не само монтирование сокета, а членство в
у нее на каждой машине свой GID, он записан в `.env`, а узнать его можно группе `docker`: у неё на каждой машине свой GID. Он записан в `.env`, а узнать
командой `getent group docker`. его можно командой `getent group docker`.
+5 -5
View File
@@ -22,12 +22,12 @@
и покажет, за что отвечает каждый слой. Пока главы нет, смотрите и покажет, за что отвечает каждый слой. Пока главы нет, смотрите
[справочник хранилища](../architecture/storage.md). [справочник хранилища](../architecture/storage.md).
### Потоки Airflow и пульт мира ### Потоки Airflow и даги модельного времени
Пульт играет модельный день, а дневной конвейер раскладывает данные по слоям. Шаговый даг играет модельный день, а дневной конвейер раскладывает данные по
Глава поможет увидеть их связь и роль каждого дага. Пока главы нет, смотрите слоям. Глава поможет увидеть их связь и роль каждого дага. Пока главы нет,
[справочник конвейера](../architecture/etl.md) и смотрите [справочник конвейера](../architecture/etl.md) и [справочник мира и
[справочник мира и его пульта](../architecture/world.md). дагов времени](../architecture/world.md).
### Мониторинг: куда смотреть ### Мониторинг: куда смотреть
+3 -3
View File
@@ -62,9 +62,9 @@
## Ритм пересборки: витрина переживает источник ## Ритм пересборки: витрина переживает источник
Таблицы брака живут месяц (`TTL`), сырье — трое суток. Витрина живет без Таблицы брака живут месяц (`TTL`), сырые данные — трое суток. Витрина живет без
срока: сводка «сколько брака было в марте» нужна и после того, как сами срока: сводка «сколько брака было в марте» нужна и после того, как сами строки
строки марта удалены. марта удалены.
Отсюда правило прогона: пересобираются **только те дни загрузки, которые Отсюда правило прогона: пересобираются **только те дни загрузки, которые
еще есть в источнике**. Спроси у источника список живых дней и замени еще есть в источнике**. Спроси у источника список живых дней и замени
+64 -68
View File
@@ -272,12 +272,13 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
Классы пересекаются — приоритет: `cancelled` > `lost_event` > Классы пересекаются — приоритет: `cancelled` > `lost_event` >
`duplicate_event` > `amount_delta` > `match`. `duplicate_event` > `amount_delta` > `match`.
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов впервые
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), ориентир
ориентир ~10%. Точные доли зафиксированы замером на каноническом мире ~10%. Точные доли зафиксированы замером на каноническом мире (28 дней,
(28 дней, 2026-08-30, #194) — запись в «Что проверено» 2026-08-30, #194) — запись в «Что проверено» [карты
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и проверок](../architecture/testing.md); паспорт хранит счётчики наблюдаемых
только по дням с закрытым окном (раздел 8). классов после приоритета — в заказах и только по дням с закрытым окном (раздел
8).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) — Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
механически дублирует B. механически дублирует B.
@@ -311,14 +312,13 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
запуск пишет полный текущий набор различных пар; предыдущий результат не запуск пишет полный текущий набор различных пар; предыдущий результат не
служит источником следующего. служит источником следующего.
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») — - **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
параметр мира; значение фиксирует эта спека: 15%, детерминировано параметр мира; значение фиксирует эта спека: 15%, детерминировано от seed.
от seed. Ядро лабы: Ядро лабы: `uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам. Константа мира: каждый двухкуковый покупатель делает минимум по одному заказу
Константа мира: каждый двухкуковый покупатель делает минимум по одному с каждой куки — иначе вторая кука не попадает в карту соответствий (она
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий строится только из покупок) и лаба не воспроизводится. Число таких пар растёт,
(она строится только из покупок) и лаба не воспроизводится. Число таких пока мир едет, и в паспорт не кладётся ([что хранит
пар растёт, пока мир едет, и в опись не кладётся паспорт](../architecture/orders/inventory.md)).
([что хранит опись](../architecture/orders/inventory.md)).
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и - Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде. **«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
@@ -339,16 +339,14 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
VIEW поверх локальной таблицы проявляются в дашборде сами. VIEW поверх локальной таблицы проявляются в дашборде сами.
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна - **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели. consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели. У
У заказов приём другого рода — пакетный забор без MV, чтец на одной ноде, заказов приём другого рода — пакетный забор без MV, чтец на одной ноде, топик
топик `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)). `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
Раскладку в обоих случаях решает ключ: Раскладку в обоих случаях решает ключ: события — по `cityHash64(ClientID)`
события — по `cityHash64(ClientID)` (см. 1.3), заказы — (см. 1.3), заказы — `cityHash64(order_id)`, STG — `cityHash64(сырой строки)`;
`cityHash64(order_id)`, сырьё STG — `cityHash64(сырой строки)`; полный полный список и доводы — в [доке хранилища](../architecture/storage.md). Урок
список и доводы — в [доке хранилища](../architecture/storage.md). Урок «какая нода читала топик — меняется между прогонами, куда легли данные — нет»
«какая нода читала топик — меняется между прогонами, куда легли данные — живёт на событиях: при пакетном заборе читает та нода, которую спросили.
нет» живёт на событиях: при пакетном заборе читает та нода, которую
спросили.
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`, - **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей `EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
сообщения сверяется с контрактным; строка с NULL среди опорных колонок сообщения сверяется с контрактным; строка с NULL среди опорных колонок
@@ -444,23 +442,24 @@ README.
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже | | DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья — суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырых данных
см. [доку хранилища](../architecture/storage.md). — см. [доку хранилища](../architecture/storage.md). Потребительские запросы и
Потребительские запросы и витрины читают сущности DDS через `_v` с явным витрины читают сущности DDS через `_v` с явным списком колонок; `_rep` и `_dist`
списком колонок; `_rep` и `_dist` служат загрузке, диагностике и обслуживанию. служат загрузке, диагностике и обслуживанию. Договор держится конвенцией, а не
Договор держится конвенцией, а не правами: модель доступа описана в правами: модель доступа описана в [ADR 0007](../adr/0007-clickhouse-access.md),
[ADR 0007](../adr/0007-clickhouse-access.md), договор чтения — в договор чтения — в [доке хранилища](../architecture/storage.md), «Имена
[доке хранилища](../architecture/storage.md), «Имена объектов». объектов».
Заказы принимаются **пакетным забором** ([ADR Заказы принимаются **пакетным забором** ([ADR
0008](../adr/0008-order-ingestion.md), [ADR 0008](../adr/0008-order-ingestion.md), [ADR
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у событий, но 0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow — событий, но матвью к нему не привязана, и порцию в STG забирает шаг под
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска. управлением Airflow — одно прямое чтение вставляет её в `stg.orders_raw` с
Один следующий `task_id` двумя последовательными запросами пишет годные строки `_load_id` запуска. Один следующий `task_id` двумя последовательными запросами
и ошибки из того же среза. `ods.order` выбирает текущую версию по максимальной пишет годные строки и ошибки из того же среза. `ods.order` выбирает текущую
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Слой версию по максимальной `snapshot_date` ([ADR
сырья остаётся точкой повтора и разбора одной принятой порции. 0014](../adr/0014-order-version-snapshot-date.md)). Слой Сырой слой остаётся
точкой повтора и разбора одной принятой порции.
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия: Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию `ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
@@ -531,31 +530,28 @@ Kafka день переигрывается генератором заново:
пачками), Postgres остаётся только служебной базой Airflow. Смешанность пачками), Postgres остаётся только служебной базой Airflow. Смешанность
ландшафта выражена режимами и частотами, а не второй трубой. ландшафта выражена режимами и частотами, а не второй трубой.
## 8. Эталонный мир и опись ## 8. Эталонный мир и паспорт
В git хранится только опись эталонного мира; сам снимок генерируется на В git хранится только паспорт эталонного мира; сам снимок генерируется на месте
месте — при `make up` и при проверках (решение развилки «Производительность» — при `make up` и при проверках (решение развилки «Производительность» карты
карты #26, подробности — [спека генератора](2026-08-01-generator.md), #26, подробности — [спека генератора](2026-08-01-generator.md), раздел 5).
раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней снято на этапе
снято на этапе 7. Опись несёт 7. Паспорт несёт шапку с каноническим зерном и версией генератора, контрольные
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман карты
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман #10) закрыта этим же ходом: версионируется паспорт. Контрольные числа паспорта:
карты #10) закрыта этим же ходом: версионируется опись.
Контрольные числа описи:
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание - по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями; «слепок переснимается и даёт те же байты» паспорт сторожит наравне с днями;
- заказная сторона: счётчики наблюдаемых классов расхождений после - заказная сторона: счётчики наблюдаемых классов расхождений после
приоритета; каждый показывает число заказов и хранится только для дня с приоритета; каждый показывает число заказов и хранится только для дня с
закрытым окном (читатель — техническая проверка `classes_vs_inventory` закрытым окном (читатель — техническая проверка `classes_vs_inventory` звена
звена DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для общего числа
общего числа заказов и выручки в описи пока нет: для них не назван заказов и выручки в паспорте пока нет: для них не назван читатель;
читатель;
- контрольные числа идентичности не заводятся: uniq известных пользователей - контрольные числа идентичности не заводятся: uniq известных пользователей
и число двухкуковых покупателей растут, пока мир едет, а счётчик без и число двухкуковых покупателей растут, пока мир едет, а счётчик без
названного читателя в опись не кладётся названного читателя в паспорт не кладётся ([что хранит
([что хранит опись](../architecture/orders/inventory.md)). паспорт](../architecture/orders/inventory.md)).
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено» Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
[карты проверок](../architecture/testing.md). [карты проверок](../architecture/testing.md).
@@ -572,7 +568,7 @@ v2 стартует пустым, поэтому объём ниже — это
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность | | SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов | | Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов |
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла | | Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
| Эталонный мир | пересборка снимка на месте, счётчики описи, чек-скрипты | M–L | | Эталонный мир | пересборка снимка на месте, счётчики паспорта, чек-скрипты | M–L |
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды | | Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR | | Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
@@ -619,8 +615,8 @@ v2 стартует пустым, поэтому объём ниже — это
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map, 4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
выручка, сверка A+C. выручка, сверка A+C.
5. Airflow: `etl_pipeline` (партиционная переобработка). 5. Airflow: `etl_pipeline` (партиционная переобработка).
6. Расхождения B+D и опоздания; счётчики описи. 6. Расхождения B+D и опоздания; счётчики паспорта.
7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация 7. Эталонный мир: паспорт и пересборка снимка, чек-скрипты. CI-генерация
снята (спека генератора, раздел 5). снята (спека генератора, раздел 5).
8. Superset-дашборд v2. 8. Superset-дашборд v2.
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов 9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
@@ -658,15 +654,15 @@ ADR 0002 с приёмочными совпадают почти целиком.
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом. расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной - Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг самопроверки
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и забраны в этап 4 резолюцией #135, потому что витрина менти и конвейер, в
конвейер, в который она встает, проектируются вместе. Сюда же — который она встает, проектируются вместе. Сюда же — необязательная лаба «свой
необязательная лаба «свой дашборд в Superset» (#197): она стоит на дашборд в Superset» (#197): она стоит на витринах домашек DM и продумана до
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как готового ТЗ в этапе 8, тогда как остальные лабы пока на уровне идей. Единый
остальные лабы пока на уровне идей. Единый стиль блока стиль блока домашек — за работой учебного пути. Спека даёт будущим лабам
домашек — за работой учебного пути. Спека даёт будущим лабам только только опорные точки — контрольные числа паспорта (сверка, идентичность).
опорные точки — контрольные числа описи (сверка, идентичность). Явное Явное следствие: после этапа 9 стенд работает, но учебного пути на нём ещё
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет. нет.
- Инкрементальный ETL (#8) — свой issue. - Инкрементальный ETL (#8) — свой issue.
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд. - Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные - TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
+132 -139
View File
@@ -41,10 +41,10 @@
конструкция внутри оправдана только наблюдаемым эффектом: без него это конструкция внутри оправдана только наблюдаемым эффектом: без него это
сложность, за которую никто не заплатил. сложность, за которую никто не заплатил.
- **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств - **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт мира сдвигает почти все числа разом — генератор случайных чисел выдаёт другую
другую последовательность. Опираться на конкретные числа поэтому нечем. последовательность. Опираться на конкретные числа поэтому нечем. Хеши паспорта
Хеши описи обещают не постоянство чисел, а то, что их сдвиг не пройдёт обещают не постоянство чисел, а то, что их сдвиг не пройдёт молча: пересобрал
молча: пересобрал опись — увидел diff. паспорт — увидел diff.
- **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он - **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он
правкой генератора после взгляда на получившиеся распределения. Оборона, правкой генератора после взгляда на получившиеся распределения. Оборона,
которая дороже ошибки, не заводится. которая дороже ошибки, не заводится.
@@ -59,15 +59,15 @@
модельный день D — функция (зерно, D). Между прогонами живут только зерно модельный день D — функция (зерно, D). Между прогонами живут только зерно
и позиция на оси времени. и позиция на оси времени.
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка — - **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания. хешами паспорта. Транспорт (офсеты Kafka, темп) — вне обещания.
- **Схема — контракт генератора.** Python-модуль с чистыми данными; - **Схема — контракт генератора.** Python-модуль с чистыми данными;
хранилище строится по рендеренной документации, границу сторожит строгий хранилище строится по рендеренной документации, границу сторожит строгий
приём на стороне хранилища. приём на стороне хранилища.
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические - **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
байты; приёмники — файл, Kafka пачкой, Kafka с темпом. байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со - **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со
стартовым миром — 8 дней; стартовым миром — 8 дней; в git — только паспорт мира; автоматических порогов
в git — только опись мира; автоматических порогов по времени нет. по времени нет.
## 1. Модель мира ## 1. Модель мира
@@ -109,23 +109,21 @@
начинается в D0) — только дают, кому возвращаться в первые дни: дневная начинается в D0) — только дают, кому возвращаться в первые дни: дневная
аудитория на полке с самого D0, разгона «пустого магазина» нет. аудитория на полке с самого D0, разгона «пустого магазина» нет.
- **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица - **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица
здесь — человек, не кука: план помечает часть людей покупателями здесь — человек, не кука: план помечает часть людей покупателями (доля —
(доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают вторую куку.
вторую куку. Обе куки пары принадлежат одному человеку одной когорты; Обе куки пары принадлежат одному человеку одной когорты; вторая рождается в
вторая рождается в пределах его окна активности, без фиксированного пределах его окна активности, без фиксированного зазора — по тому же
зазора — по тому же затухающему профилю, что и возвраты (уточнение при затухающему профилю, что и возвраты (уточнение при исполнении #38,
исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит. 2026-08-02): куку человек заводит, пока ещё ходит. Равные шансы по всему окну
Равные шансы по всему окну означали бы вторую куку у давно ушедшего означали бы вторую куку у давно ушедшего человека — и вчетверо меньше пар,
человека — и вчетверо меньше пар, реализованных внутри снимка. реализованных внутри снимка. Каждой паре план назначает дни гарантированных
Каждой паре план назначает дни гарантированных заказов: по заказов: по одному на куку, из дней активности этой куки, на оси от D0 и
одному на куку, из дней активности этой куки, на оси от D0 и позже; позже; человеку предыстории, чьё окно активности таких дней не оставляет, пара
человеку предыстории, чьё окно активности таких дней не оставляет, не назначается. День-функция обязана назначенные заказы реализовать; остальные
пара не назначается. День-функция обязана назначенные заказы покупки — вольные, их решает генератор торговых событий (#40). План считает
реализовать; остальные покупки — вольные, их решает генератор торговых пары, реализованные в запрошенном горизонте; паспорт это число не хранит — оно
событий (#40). План считает пары, реализованные в запрошенном горизонте; растёт вместе с миром. Условность в данных не видна: дни назначены той же
опись это число не хранит — оно растёт вместе с миром. Условность в данных случайностью, просто брошенной планом один раз.
не видна: дни назначены той же случайностью, просто брошенной планом один
раз.
- **Своя ось модельного времени.** Ось событий начинается в - **Своя ось модельного времени.** Ось событий начинается в
фиксированный день D0 (понедельник — см. раздел 5); реальный фиксированный день D0 (понедельник — см. раздел 5); реальный
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
@@ -133,10 +131,10 @@
зависящая (значение — раздел 9). Между прогонами живут только зерно и зависящая (значение — раздел 9). Между прогонами живут только зерно и
позиция на оси: выключенный ноутбук — мир замер, потом продолжил. позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт - **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
с эталонного снимка, дальше «прожить следующий день» — явное действие. с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»;
«дышат»; включается по требованию — постоянным фоном идёт, только пока включается по требованию — постоянным фоном идёт, только пока включён
включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)). выключатель `world_live` ([ADR 0009](../adr/0009-world-control.md)).
- **Граница суток — единственный структурный шов.** Сессии режутся по ней, - **Граница суток — единственный структурный шов.** Сессии режутся по ней,
дневная партиция самодостаточна; в конце модельного дня — слепок заказов. дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
День проживается целиком, полдня не бывает: недожитый из-за обрыва день День проживается целиком, полдня не бывает: недожитый из-за обрыва день
@@ -148,14 +146,13 @@
Отклонено с доводами: Отклонено с доводами:
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по - *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
дням, требует чекпоинтов, счётчики описи узнаваемы только постфактум; дням, требует чекпоинтов, счётчики паспорта узнаваемы только постфактум; ни
ни один урок стенда на старении не стоит. один урок стенда на старении не стоит.
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы - *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
выводить в каждом дне заново — тот же план мира, но неявный и размазанный. выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
- *Привязка модельного времени к реальному календарю* (T-1 с догоном): - *Привязка модельного времени к реальному календарю* (T-1 с догоном):
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты
даты эталонного мира зависимыми от даты запуска, опись теряет эталонного мира зависимыми от даты запуска, паспорт теряет воспроизводимость.
воспроизводимость.
Отклонено при исполнении #38 (2026-08-02): Отклонено при исполнении #38 (2026-08-02):
@@ -170,9 +167,9 @@
константы — потолка одновременно живущих кук, которого в жизни нет и константы — потолка одновременно живущих кук, которого в жизни нет и
который менти нечем объяснить. который менти нечем объяснить.
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не - *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
гарантия — однажды опись покраснеет, а чинить нечем, кроме смены гарантия — однажды паспорт покраснеет, а чинить нечем, кроме смены зерна; при
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев, этом несклеенная пара в данных неотличима от двух незнакомцев, так что реализм
так что реализм этой лотереи невидим. этой лотереи невидим.
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор - *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
«все пары покупают в первый день» виден в данных ровно там, куда лаба «все пары покупают в первый день» виден в данных ровно там, куда лаба
склейки смотрит пристальнее всего. Условность допустима, пока она не склейки смотрит пристальнее всего. Условность допустима, пока она не
@@ -185,31 +182,29 @@
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же - **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени. набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
Снимок при пересборке побайтово совпадает: канонический порядок ключей и Снимок при пересборке побайтово совпадает: канонический порядок ключей и
строк; сверка — по хешам описи, а два локально пересобранных снимка строк; сверка — по хешам паспорта, а два локально пересобранных снимка
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне сравнимы обычным diff — пустой означает «ничего не изменилось». Вне обещания —
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала, транспорт: офсеты и партиции Kafka, какая нода прочитала, `_load_ts`, темп
`_load_ts`, темп живого дня. живого дня.
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock` - **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL и внутри канонического контейнера — то есть везде Linux, на маке и в WSL тоже;
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость
скрипт «пересгенерируй день N — сравни хеш с описью». Расхождение на любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с
любой платформе — баг генератора, а не допуск. паспортом». Расхождение на любой платформе — баг генератора, а не допуск.
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток - **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
состава мира, ветвящийся по номеру дня на когорты плана (состав состава мира, ветвящийся по номеру дня на когорты плана (состав спрашивается
спрашивается по дням — раздел 1; уточнение при исполнении #38). по дням — раздел 1; уточнение при исполнении #38). Дни предыстории
Дни предыстории отрицательны, а позиция в дереве — неотрицательное отрицательны, а позиция в дереве — неотрицательное целое, поэтому у
целое, поэтому у предыстории своя ветвь состава, отдельная от оси предыстории своя ветвь состава, отдельная от оси (уточнение при исполнении
(уточнение при исполнении #38, 2026-08-02); #38, 2026-08-02); (зерно, день) → подпоток дня → именованные подпотоки
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, компонентов: трафик, торговые события, заказная и событийная стороны (судьба
торговые события, заказная и событийная стороны (судьба заказов и порча заказов и порча событийного потока) — в фиксированном порядке (уточнение
событийного потока) — в фиксированном порядке (уточнение [судьбой [судьбой заказа](../architecture/orders/fate.md): прежние «расхождения» и
заказа](../architecture/orders/fate.md): прежние «расхождения» и «опоздания» были названы по классам витрины, а компонент называет часть мира).
«опоздания» были названы по классам витрины, а компонент называет часть По построению: параллельный прогон равен последовательному; продление истории
мира). днём N+1 не трогает дни 1…N; правка одного компонента меняет только его часть
По построению: параллельный прогон равен последовательному; продление снимка — в паспорте меняются хеши только затронутых дней, дифф двух локальных
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет пересборок читаем.
только его часть снимка — в описи меняются хеши только затронутых
дней, дифф двух локальных пересборок читаем.
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через - **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
расширением `spawn_key`, потомок полностью определяется парой расширением `spawn_key`, потомок полностью определяется парой
@@ -223,8 +218,8 @@
представление в клиентском `purchase` (урок мастер-спеки о расхождениях). представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним - **Канонический seed и паспорт мира.** Эталонный мир собирается одним
каноническим зерном — константой репозитория; свои зёрна менти крутит без каноническим зерном — константой репозитория; свои зёрна менти крутит без
гарантий описи. Опись хранит паспорт мира — зерно и версию гарантий паспорта. В шапке паспорта лежат зерно и версия генератора;
генератора; чек-скрипты сверяют паспорт раньше счётчиков. чек-скрипты сверяют шапку раньше счётчиков.
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны: - **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
ночной провал, обеденный и вечерний пики, различие будней и выходных; ночной провал, обеденный и вечерний пики, различие будней и выходных;
пики — до ~2× среднего. С детерминизмом профиль совместим: это часть пики — до ~2× среднего. С детерминизмом профиль совместим: это часть
@@ -241,8 +236,8 @@
«параллельно равно последовательно» недостижимо. «параллельно равно последовательно» недостижимо.
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно - *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная пересборка
пересборка описи одним PR. паспорта одним PR.
## 3. Контракт схемы ## 3. Контракт схемы
@@ -289,9 +284,9 @@
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт - **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
упорядоченный поток канонических байтов — единственное место, где событие упорядоченный поток канонических байтов — единственное место, где событие
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш для
для пересборки и проверок описи), Kafka пачкой — пакетный режим, пересборки и проверок паспорта), Kafka пачкой — пакетный режим, Kafka с темпом
Kafka с темпом ×60 — живой день. Новых топиков нет. ×60 — живой день. Новых топиков нет.
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу - **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
отдельным сообщением. Контракт транспорта, не деталь реализации — сторона отдельным сообщением. Контракт транспорта, не деталь реализации — сторона
@@ -299,9 +294,9 @@
([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких ([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких
событий в одно сообщение сломала бы разбор целиком. событий в одно сообщение сломала бы разбор целиком.
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`, - **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырых данных:
смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и весь смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте
разбирает событие глазами, а число эпохи этот урок убивает. Колонка и разбирает событие глазами, а число эпохи этот урок убивает. Колонка
`ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт `ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт
Метрика. Метрика.
@@ -321,9 +316,9 @@
первым. Записана форма всё равно здесь — иначе каждый выберет своё, и первым. Записана форма всё равно здесь — иначе каждый выберет своё, и
разойдётся это уже после приёмки. разойдётся это уже после приёмки.
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз, - **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
numpy-массивы и datetime сериализует нативно (заметка исследования #31). numpy-массивы и datetime сериализует нативно (заметка исследования #31). Смена
Смена библиотеки меняет канонические байты, поэтому проходит как библиотеки меняет канонические байты, поэтому проходит как обновление
обновление зависимости: осознанная пересборка описи одним PR. зависимости: осознанная пересборка паспорта одним PR.
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции: - **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
потерял — пересчитал. В git снимок не попадает (раздел 5). потерял — пересчитал. В git снимок не попадает (раздел 5).
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает - **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
@@ -341,7 +336,7 @@
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне - *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
обещания детерминизма, retention конечен, в git топик не положишь, хеш с обещания детерминизма, retention конечен, в git топик не положишь, хеш с
описью не сверишь; Kafka на стенде — труба, не хранилище (раздел 7 паспортом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
мастер-спеки). мастер-спеки).
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено - *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
через дедуп, канон держит единственный сериализатор, а не диск; файл через дедуп, канон держит единственный сериализатор, а не диск; файл
@@ -360,27 +355,25 @@
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей — ~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
правдоподобный средний магазин. правдоподобный средний магазин.
- **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник - **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник
по понедельник, полная неделя и один закрытый день за окном K = 7. по понедельник, полная неделя и один закрытый день за окном K = 7. Обещание в
Обещание в 14 дней снято на этапе 7 (решение владельца 2026-08-30): 14 дней снято на этапе 7 (решение владельца 2026-08-30): у дней 8–13 не
у дней 8–13 не нашлось читателя — лабы проигрывают мир дальше без описи, нашлось читателя — лабы проигрывают мир дальше без паспорта, транспорт байты с
транспорт байты с описью не сверяет, а проверке «пересгенерируй день N — паспортом не сверяет, а проверке «пересгенерируй день N — сравни хеш» день 3
сравни хеш» день 3 годится так же, как день 13. Замеры и тесты, меряющие годится так же, как день 13. Замеры и тесты, меряющие мир на окне в две
мир на окне в две недели, не меняются: мир — функция без края, окну недели, не меняются: мир — функция без края, окну замера паспорт не нужен.
замера опись не нужна. Удлинение описи остается дешевым ходом Удлинение паспорта остаётся дешёвым ходом (пересборка) и делается, когда
(пересборка) и делается, когда читатель появится — например, если читатель появится — например, если растяжке `classes_vs_inventory` понадобится
растяжке `classes_vs_inventory` понадобится сверять больше одного сверять больше одного закрытого дня.
закрытого дня. - **В git — только паспорт, снимок не хранится.** Снимок генерируется при
- **В git — только опись, снимок не хранится.** Снимок генерируется при
создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в
git не хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки git не хранят. Паспорт несёт шапку, счётчики и хеши по дням; проверки «пустой
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней. git diff» и «пересгенерируй день N — сравни хеш» живут на нём. Первый `make
Первый `make up` пустого стенда — живая демонстрация детерминизма; готовый мир up` пустого стенда — живая демонстрация детерминизма; готовый мир повторный
повторный подъём не пересчитывает. Честная потеря — подъём не пересчитывает. Честная потеря — страховка на случай платформенного
страховка на случай платформенного бага: раньше менти с расходящимися бага: раньше менти с расходящимися байтами мог взять готовый снимок из git,
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек теперь он упрётся в красный чек паспорта; смягчение — дисциплина целочисленной
описи; смягчение — дисциплина целочисленной случайности (раздел 2): случайности (раздел 2): расхождения amd64/arm64 сняты устройством, а не
расхождения amd64/arm64 сняты устройством, а не сторожем. CI ради одного сторожем. CI ради одного прогона не заводится (решение владельца 2026-08-30).
прогона не заводится (решение владельца 2026-08-30).
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты, - **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с, суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
в пиковые часы сильных дней — до ~100. Число — в пиковые часы сильных дней — до ~100. Число —
@@ -399,7 +392,7 @@
| Операция | Ориентир | | Операция | Ориентир |
|---|---| |---|---|
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) | | Генерация одного дня | 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 года) | | Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
| Лаг живого дня | секунды | | Лаг живого дня | секунды |
@@ -460,9 +453,9 @@
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data - **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
contract — хранилище пишется по документации, границу сторожит строгий contract — хранилище пишется по документации, границу сторожит строгий
приём (раздел 3 здесь). приём (раздел 3 здесь).
- **Раздел 8**: артефакт `data/startup_history/` в git заменён описью; - **Раздел 8**: артефакт `data/startup_history/` в git заменён паспортом;
снимок генерируется на месте (раздел 5 здесь). Туман «политика снимок генерируется на месте (раздел 5 здесь). Туман «политика версионирования
версионирования артефакта» закрыт этим же ходом: версионируется опись. артефакта» закрыт этим же ходом: версионируется паспорт.
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования - **Раздел 11**: пункт «до этапа 3 зафиксировать требования
производительности» закрыт числами раздела 5. производительности» закрыт числами раздела 5.
- Мелкие согласования там, где текст опирался на артефакт в git: источник - Мелкие согласования там, где текст опирался на артефакт в git: источник
@@ -491,24 +484,25 @@
теперь есть. теперь есть.
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг - **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
по расписанию (~раз в 24 минуты); генератор не дорабатывается. по расписанию (~раз в 24 минуты); генератор не дорабатывается.
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 — - **Позиция на оси времени** (хвост отдан дагам модельного времени раньше этапа
5 —
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не [ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
она переменной Airflow: отсутствие переменной означает, что стартовый мир она переменной Airflow: отсутствие переменной означает, что стартовый мир ещё
ещё не создан. В обычной жизни её ставит даг пульта, который сыграл день, — не создан. В обычной жизни её ставит шаговый даг, который сыграл день, —
значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации используют `-1` для незавершённой очистки, `0` для чистого мира до генерации и
и ставят первый несыгранный день только после приёма стартового мира ставят первый несыгранный день только после приёма стартового мира ([ADR
([ADR 0013](../adr/0013-world-lifecycle.md)). 0013](../adr/0013-world-lifecycle.md)). Довод — генератор отдельная и
Довод — генератор отдельная и заменяемая сущность, привязывать его к заменяемая сущность, привязывать его к хранилищу незачем. Полный `make clean`
хранилищу незачем. Полный `make clean` сносит метаданные Airflow вместе с сносит метаданные Airflow вместе с данными ClickHouse, а узкая пересборка явно
данными ClickHouse, а узкая пересборка явно сводит сохранённую позицию с сводит сохранённую позицию с заново залитым миром. Расхождение переменной с
заново залитым миром. данными (менти почистил партицию руками) лечится документацией, а не сторожем:
Расхождение переменной с данными (менти почистил партицию руками) лечится постоянная сверка была бы проверкой, красной в норме ([ADR
документацией, а не сторожем: постоянная сверка была бы проверкой, красной 0002](../adr/0002-monitoring-scope.md)).
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)). - **Этап 7 (эталонный мир)**: пересборка обновляет паспорт мира. Отдельного
- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт. артефакта у неё нет. CI-генерация на amd64 и arm64 снята — резолюция в разделе
CI-генерация на amd64 и arm64 снята — резолюция в разделе 5. 5.
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя — - **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
готовая демонстрация идемпотентности конвейера. готовая демонстрация идемпотентности конвейера.
@@ -521,10 +515,10 @@
[ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора [ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора
записывают в ADR и в спеке этапа, на котором их принимают. записывают в ADR и в спеке этапа, на котором их принимают.
Журнал собран по темам, но дата и тикет остались у каждого решения. По Журнал собран по темам, но дата и тикет остались у каждого решения. По журналу
журналу видно, какие границы затрагивала каждая правка: иногда она меняла видно, какие границы затрагивала каждая правка: иногда она меняла сразу план
сразу план состава, воронку и каталог. При #40 это было дешево — опись еще состава, воронку и каталог. При #40 это было дёшево — паспорт мира ещё не
не собрали; позже такой ход потребовал бы пересборки описи. собрали; позже такой ход потребовал бы пересборки паспорта мира.
### План состава и числа мира ### План состава и числа мира
@@ -547,10 +541,10 @@
покупателей ~190 в день, что дает конверсию ~2% на сессию. покупателей ~190 в день, что дает конверсию ~2% на сессию.
- **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с - **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с
контрактом схемы: все числа мира собраны в одном месте, которое менти может контрактом схемы: все числа мира собраны в одном месте, которое менти может
крутить. Правка модуля меняет мир, и проверка описи честно краснеет: опись крутить. Правка модуля меняет мир, и проверка паспорта честно краснеет:
сторожит только канон. Вне модуля остается то, что мира не меняет, — свое паспорт сторожит только канон. Вне модуля остается то, что мира не меняет, —
зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и свое зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и
переопределения окружением — переменная мира, которой нет в паспорте описи; переопределения окружением — переменная мира, которой нет в шапке паспорта;
файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и
валидацией (довод раздела 3 против YAML). валидацией (довод раздела 3 против YAML).
- **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение - **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение
@@ -749,8 +743,8 @@
четвертям цены и по медиане, потому что одни только края списка обманываются четвертям цены и по медиане, потому что одни только края списка обманываются
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё
не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять
правкой; описи нужен хеш каталога (хвост #42) тем более, потому что теперь в правкой; паспорту нужен хеш каталога (хвост #42) тем более, потому что теперь
файле живёт ещё и поведение. в файле живёт ещё и поведение.
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%** - **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%**
(#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои (#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои
позиции, а визит с намерением должен класть почти все сравненное; обе границы позиции, а визит с намерением должен класть почти все сравненное; обе границы
@@ -764,7 +758,7 @@
живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе
ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий
предел считается в другой валюте — дневной бюджет событий, на котором стоит предел считается в другой валюте — дневной бюджет событий, на котором стоит
опись #42. Если будущая правка упрется в него, двигать надо бюджет и его паспорт #42. Если будущая правка упрется в него, двигать надо бюджет и его
причины, а не долю. причины, а не долю.
### Номер заказа, промокод и деньги ### Номер заказа, промокод и деньги
@@ -933,28 +927,27 @@
подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не
нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине. нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине.
### Опись мира и словарь ### Паспорт мира и словарь
- **Опись мира одна** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7, - **Паспорт мира один** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7,
2026-08-30). Стартовый мир этапа 2 — первые восемь дней, 2026-08-30). Стартовый мир этапа 2 — первые восемь дней, понедельник по
понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7. понедельник: полная неделя и первый замкнутый цикл K = 7.
`data/world-inventory.json` — та самая опись, которую обещает раздел 5. `data/world-inventory.json` — тот самый паспорт, который обещает раздел 5.
Этап 7 оставил ее восьмидневной: у хвоста в шесть дней не нашлось читателя Этап 7 оставил его восьмидневным: у хвоста в шесть дней не нашлось читателя
(резолюция — в разделе 5); вырастет она продолжением этого же файла, а не (резолюция — в разделе 5); вырастет он продолжением этого же файла, а не
вторым. В JSON лежат вторым. В JSON лежат шапка паспорта — зерно, версия генератора и хеш каталога
паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с — и строки дней с датой, числом событий и хешем. Паспорт собирает `make
датой, числом событий и хешем. Опись собирает `make inventory`, свежесть inventory`, свежесть сторожит `test_inventory.py` тем же способом, что
сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки. свежесть описания выгрузки. Отклонено: *два файла, младший и полный* — две
Отклонено: *два файла, младший и полный* — две правды об одном мире и лишнее правды об одном мире и лишнее слово в словаре.
слово в словаре.
- **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07). - **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07).
Правка цены меняет мир так же молча, как правка кода. Хеши дней и без Правка цены меняет мир так же молча, как правка кода. Хеши дней и без
каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что
менялось: разошлись дни и каталог — CSV, только дни — код. менялось: разошлись дни и каталог — CSV, только дни — код.
- **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»** - **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»**
(#42, решение владельца 2026-08-07). Обычные русские слова убирают пару (#42, решение владельца 2026-08-07; позднее тикет #214 сменил «опись мира» на
«манифест / мини-манифест» и необходимость различать две сущности там, где «паспорт мира»). Обычные русские слова убирают пару «манифест / мини-манифест»
вещь одна. и необходимость различать две сущности там, где вещь одна.
### Открытое на момент заморозки ### Открытое на момент заморозки
+21 -21
View File
@@ -12,11 +12,11 @@ DDL и двумя одноразовыми службами заливал во
Граница владения не совпадала с процессом, и это видно по следствиям. Успешная Граница владения не совпадала с процессом, и это видно по следствиям. Успешная
отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу
после записи в топик, до приёма сообщений хранилищем. после записи в топик, до приёма сообщений хранилищем. Повторный `make up`
Повторный `make up` смешивал подъём служб с повторной заливкой данных. Узкая смешивал подъём служб с повторной заливкой данных. Узкая пересборка хранилища
пересборка хранилища сохраняла в Kafka сообщения и офсеты прежнего мира, потому сохраняла в Kafka сообщения и офсеты прежнего мира, потому что чинила только
что чинила только ClickHouse. Позиция пульта правилась снаружи Airflow — тем, ClickHouse. Позиция на оси правилась снаружи Airflow — тем, кто миром не
кто миром не управляет. управляет.
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат **Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
прикладная схема, данные мира и позиция модельного времени, — и Compose, прикладная схема, данные мира и позиция модельного времени, — и Compose,
@@ -63,7 +63,7 @@ Airflow держит прикладные топики Kafka, каноничес
Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`, Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`,
`data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого `data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого
собирается канонический образ генератора, из второго читается опись мира, а собирается канонический образ генератора, из второго читается паспорт мира, а
последний не пускает в контекст сборки локальные окружения и кэши. Запросы последний не пускает в контекст сборки локальные окружения и кэши. Запросы
стенда приезжают прежним монтированием `sql/`. стенда приезжают прежним монтированием `sql/`.
@@ -101,7 +101,7 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
кода: сборкой образа при подъёме больше никто не занимается. кода: сборкой образа при подъёме больше никто не занимается.
5. Играет стартовые дни одним прогоном `batch` того же образа. 5. Играет стартовые дни одним прогоном `batch` того же образа.
6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего 6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего
сценария, и оно точное: сумма строк `ods.event_dist` за даты описи и строк сценария, и оно точное: сумма строк `ods.event_dist` за даты паспорта и строк
`ods.event_errors_dist` не меньше суммы событий из `ods.event_errors_dist` не меньше суммы событий из
[`data/world-inventory.json`](../../data/world-inventory.json). «Что-то [`data/world-inventory.json`](../../data/world-inventory.json). «Что-то
приехало» условием не считается. приехало» условием не считается.
@@ -136,21 +136,21 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
третьим местом с той же последовательностью, а первое же расхождение между третьим местом с той же последовательностью, а первое же расхождение между
дагами пришлось бы прятать в его параметрах. дагами пришлось бы прятать в его параметрах.
### Сторожа у работников мира ### Сторожа у шаговых дагов
`world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше `world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше
`WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг: `WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг:
поднять стенд или пересобрать хранилище. Выключатель `world_live` своего поднять стенд или пересобрать хранилище. Выключатель `world_live` своего сторожа
сторожа не получает — он краснеет вслед за работником. не получает — он краснеет вслед за шаговым дагом.
`orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда `orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда
готового мира ещё нет. готового мира ещё нет.
## Состояния позиции ## Состояния позиции
`world_position` остаётся единственным состоянием пульта. `world_position` остаётся единственным состоянием дагов модельного времени.
| Позиция | `world_initialize` | `world_recreate` | Работники | | Позиция | `world_initialize` | `world_recreate` | Шаговые даги |
|---|---|---|---| |---|---|---|---|
| переменной нет | создаёт мир | очищает и создаёт | красный отказ | | переменной нет | создаёт мир | очищает и создаёт | красный отказ |
| `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ | | `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ |
@@ -187,9 +187,9 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо. ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо.
Поэтому сначала удаляются базы, затем топики. Поэтому сначала удаляются базы, затем топики.
**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: сырьё **Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: число
в STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на непустом строк STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на
ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта непустом ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
делает себе сам и тут же видит. делает себе сам и тут же видит.
@@ -197,10 +197,10 @@ ODS истинно сразу — этим оно и ограничено, об
`world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом `world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline` режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот. и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик. Одновременная пересборка добавляет ожидания и загоняет стенд в тупик. Поэтому
Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live` пересборка — явная операция обслуживания: оператор ставит `world_live` на паузу
на паузу и даёт работникам доиграть. Предусловие остаётся в документации; и даёт шаговым дагам доиграть. Предусловие остаётся в документации; механизм
механизм блокировок стоил бы дороже этой строки. блокировок стоил бы дороже этой строки.
## Границы ## Границы
@@ -226,9 +226,9 @@ ODS истинно сразу — этим оно и ограничено, об
позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что
мир уже есть. мир уже есть.
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит - **`make rebuild-storage`** возвращает канонический мир: позиция проходит
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы `-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты паспорта, топики созданы
заново, офсеты групп идут с нуля, DDS и DM собраны. заново, офсеты групп идут с нуля, DDS и DM собраны.
- **Красные пути проверяются своими отказами.** Сторож работника при - **Красные пути проверяются своими отказами.** Сторож шагового дага при
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`; отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки, оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
доказывает только то, что умеет печатать «ЗЕЛЁНО». доказывает только то, что умеет печатать «ЗЕЛЁНО».
+6 -4
View File
@@ -104,14 +104,14 @@
### Ступень 6. Разбери сырые события и сверь результат со стендом ### Ступень 6. Разбери сырые события и сверь результат со стендом
Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно Менти пакетно разбирает срез событий из STG в собственную таблицу и построчно
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
отдельно от живого приема и использовать стендовый результат как образец для отдельно от живого приема и использовать стендовый результат как образец для
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
сообщения. сообщения.
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
границы называются прямо: срок работы ограничен окном хранения сырья, а границы называются прямо: срок работы ограничен окном хранения сырых данных, а
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
независимым эталоном. независимым эталоном.
@@ -150,7 +150,8 @@
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно - **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
разложить до конкретной вычеркнутой позиции и объяснить до копейки. разложить до конкретной вычеркнутой позиции и объяснить до копейки.
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а - **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
некоторые различия после истечения срока сырья уже не восстановить. некоторые различия после истечения срока хранения сырых данных уже не
восстановить.
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день - **Когда отчет за день можно считать окончательным (#188).** Отчет за день
становится окончательным лишь после того, как источник больше не может становится окончательным лишь после того, как источник больше не может
изменить этот день. изменить этот день.
@@ -191,7 +192,8 @@
потребителей создает еще одну полную копию потока. потребителей создает еще одну полную копию потока.
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах - **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
показывает потерю, которую трудно заметить по самим данным. показывает потерю, которую трудно заметить по самим данным.
- **Почему сырье удаляется по времени загрузки.** Очистка сырья живет по - **Почему сырые данные удаляются по времени загрузки.** Очистка сырых данных
идёт по
времени загрузки, а не по модельному дню. времени загрузки, а не по модельному дню.
- **Куда попадают битые строки.** Годные строки и брак делят поток без - **Куда попадают битые строки.** Годные строки и брак делят поток без
пропусков и пересечений; менти подкладывает по строке каждого класса брака пропусков и пересечений; менти подкладывает по строке каждого класса брака
+3 -3
View File
@@ -64,8 +64,8 @@
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки» - `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md)) ([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
из контракта. Документ руками не правят — пересобирают. из контракта. Документ руками не правят — пересобирают.
- `src/clickstream_generator/inventory.py` — сборка описи мира - `src/clickstream_generator/inventory.py` — сборка паспорта мира
([`data/world-inventory.json`](../data/world-inventory.json)): паспорт мира ([`data/world-inventory.json`](../data/world-inventory.json)): шапка паспорта
и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов
расхождений по заказам для дней с закрытым окном. Руками не правят — расхождений по заказам для дней с закрытым окном. Руками не правят —
пересобирают целью `make inventory`. пересобирают целью `make inventory`.
@@ -88,7 +88,7 @@
- `make typecheck` — ty: проверка типов; - `make typecheck` — ty: проверка типов;
- `make demo` — пройти стадии канонического D0 на одном экране; - `make demo` — пройти стадии канонического D0 на одном экране;
- `make docs` — пересобрать описание выгрузки; - `make docs` — пересобрать описание выгрузки;
- `make inventory` — пересобрать опись мира. - `make inventory` — пересобрать паспорт мира.
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
Superset. Одноимённые цели за разными дверями охватывают разное: Superset. Одноимённые цели за разными дверями охватывают разное:
@@ -1,15 +1,15 @@
"""Опись мира: чем стенд наполняется при подъёме и каким это обязано выйти. """Паспорт мира: чем стенд наполняется при подъёме и каким это обязано выйти.
Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не
он сам, а опись: паспорт мира, число событий и хеш байтов каждого дня, счётчики он сам, а паспорт: шапка, число событий и хеш байтов каждого дня, счётчики
классов расхождений по заказам. Сам мир пересчитывается когда угодно, а опись классов расхождений по заказам. Сам мир пересчитывается когда угодно, а паспорт
отвечает на единственный вопрос — **тот ли это мир, что был вчера**. Разошлись отвечает на единственный вопрос — **тот ли это мир, что был вчера**. Разошлись
хеши — мир уехал, и дальше уже неважно, чего от него ждали проверки. хеши — мир уехал, и дальше уже неважно, чего от него ждали проверки.
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по Дней в паспорте восемь: столько заливается в стенд при `make up`. Понедельник по
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7. понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 — Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот у хвоста не нашлось читателя. Понадобится длиннее — паспорт продолжит этот
же файл, а не заведет второй. же файл, а не заведет второй.
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем **Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
@@ -30,9 +30,9 @@
Слепок заказов — второй артефакт мира, и хешами дней он не покрыт ни при каком Слепок заказов — второй артефакт мира, и хешами дней он не покрыт ни при каком
раскладе подпотоков, поэтому у каждого отправленного слепка своя строка. Их на раскладе подпотоков, поэтому у каждого отправленного слепка своя строка. Их на
один меньше, чем дней: прогон дня 0 отправлять ещё нечего. Счёта строк у один меньше, чем дней: прогон дня 0 отправлять ещё нечего. Счёта строк у
слепка нет — опись описывает мир, а не доставку. слепка нет — паспорт описывает мир, а не доставку.
Собирается опись из каталога генератора целью `make inventory`, а свежесть её Собирается паспорт из каталога генератора целью `make inventory`, а его свежесть
сторожит тест — как и у «описания выгрузки». сторожит тест — как и у «описания выгрузки».
""" """
@@ -57,7 +57,7 @@ STARTING_DAYS = 8
def build() -> dict[str, Any]: def build() -> dict[str, Any]:
"""Опись целиком: паспорт мира, строка на день и строка на слепок. """Паспорт целиком: шапка, строка на день и строка на слепок.
Дни играются по одному и отпускаются: заказы дня остаются, потому что из Дни играются по одному и отпускаются: заказы дня остаются, потому что из
них собираются слепки, а полсотни тысяч событий восьми дней сразу в память них собираются слепки, а полсотни тысяч событий восьми дней сразу в память
@@ -83,12 +83,12 @@ def build() -> dict[str, Any]:
def render() -> str: def render() -> str:
"""Опись текстом файла: отступы в два пробела, кириллица как есть.""" """Паспорт текстом файла: отступы в два пробела, кириллица как есть."""
return json.dumps(build(), ensure_ascii=False, indent=2) + "\n" return json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
def _day(number: int, today: day_module.Day) -> dict[str, Any]: def _day(number: int, today: day_module.Day) -> dict[str, Any]:
"""Строка описи: номер дня, его дата, число событий и хеш байтов. """Строка паспорта: номер дня, его дата, число событий и хеш байтов.
Дата считается от D0 арифметикой, а не берётся из событий: ось модельного Дата считается от D0 арифметикой, а не берётся из событий: ось модельного
времени так и определена (`world.ORIGIN`), и по этой же дате счётчики времени так и определена (`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]: def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]:
"""Строка описи слепка: какой день снят, его дата и хеш отправленных байтов. """Строка паспорта слепка: какой день снят, его дата и хеш отправленных байтов.
Байты те же, что уезжают в топик `orders`, с переводом строки после Байты те же, что уезжают в топик `orders`, с переводом строки после
каждого заказа: пересъёмка слепка обязана дать их снова. каждого заказа: пересъёмка слепка обязана дать их снова.
@@ -169,12 +169,12 @@ def _digest(payload: bytes) -> str:
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser( 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 = parser.parse_args().output
output.write_text(render(), encoding="utf-8") output.write_text(render(), encoding="utf-8")
print(f"Опись мира собрана: {output}") print(f"Паспорт мира собран: {output}")
if __name__ == "__main__": if __name__ == "__main__":
@@ -15,7 +15,7 @@
Тайминги печатаются раздельно — генерация и доставка, как требует спека Тайминги печатаются раздельно — генерация и доставка, как требует спека
(раздел 5): это разные машины разной природы, и сложенные в одно число они (раздел 5): это разные машины разной природы, и сложенные в одно число они
перестают что-либо говорить. Сериализация считается частью генерации: она перестают что-либо говорить. Сериализация считается частью генерации: она
рождает те самые байты, которые сторожит опись. рождает те самые байты, которые сторожит паспорт.
**Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу **Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу
сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за
+2 -2
View File
@@ -25,8 +25,8 @@ from enum import IntEnum
import numpy as np import numpy as np
# Каноническое зерно эталонного мира — константа репозитория; опись хранит # Каноническое зерно эталонного мира — константа репозитория; паспорт хранит
# его в паспорте мира. Свои зёрна менти крутит без гарантий описи. # его в своей шапке. Свои зёрна менти крутит без гарантий паспорта.
CANONICAL_SEED = 20260601 CANONICAL_SEED = 20260601
@@ -18,7 +18,7 @@
строгий приём хранилища сверяет набор ключей (ADR 0005). строгий приём хранилища сверяет набор ключей (ADR 0005).
- **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как - **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как
`2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость `2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость
сырья: менти открывает колонку `raw` обычным клиентом и разбирает событие сырых данных: менти открывает колонку `raw` обычным клиентом и разбирает событие
глазами, а число эпохи этот урок убивает. глазами, а число эпохи этот урок убивает.
- **Одно событие — один документ JSON**, без перевода строки внутри: приёмник - **Одно событие — один документ JSON**, без перевода строки внутри: приёмник
сам решает, чем их разделить. сам решает, чем их разделить.
+3 -3
View File
@@ -2,8 +2,8 @@
Модуль — приглашение крутить: поменяйте число, пересоберите снимок и Модуль — приглашение крутить: поменяйте число, пересоберите снимок и
посмотрите, что стало с данными. Правка любой константы здесь — смена мира, посмотрите, что стало с данными. Правка любой константы здесь — смена мира,
поэтому чек описи честно покраснеет: опись сторожит только канонический мир, поэтому чек паспорта честно покраснеет: паспорт сторожит только канонический мир,
свои миры менти собирает без её гарантий (спека генератора, раздел 9). свои миры менти собирает без его гарантий (спека генератора, раздел 9).
Числа решены спекой и связаны между собой; связки сторожат тесты Числа решены спекой и связаны между собой; связки сторожат тесты
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего. `test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
@@ -28,7 +28,7 @@ COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в # D0 — первый день оси модельного времени, понедельник. Реальный календарь в
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в # модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не # `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
# зависит — иначе опись перестала бы быть воспроизводимой. # зависит — иначе паспорт перестал бы быть воспроизводимым.
ORIGIN = date(2026, 6, 1) ORIGIN = date(2026, 6, 1)
# Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы, # Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы,
+1 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
def test_demo_runs_and_shows_the_whole_chain() -> None: def test_demo_runs_and_shows_the_whole_chain() -> None:
"""Сторожит команду и стадии, а числа мира оставляет описи.""" """Сторожит команду и стадии, а проверку чисел мира оставляет паспорту."""
finished = subprocess.run( finished = subprocess.run(
["make", "--silent", "demo"], ["make", "--silent", "demo"],
check=False, check=False,
+9 -9
View File
@@ -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" INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory.json"
# Прогон, слепок которого сверяется с описью байт в байт. День любой из # Прогон, слепок которого сверяется с паспортом байт в байт. День любой из
# отправляемых; этот дешевле прочих — его окно короче. # отправляемых; этот дешевле прочих — его окно короче.
RUN_DAY = 2 RUN_DAY = 2
# День любой: хеш дня не зависит ни от соседей, ни от края описи. # День любой: хеш дня не зависит ни от соседей, ни от края паспорта.
REPLAY_DAY = 4 REPLAY_DAY = 4
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def inventory() -> dict: def inventory() -> dict:
"""Опись, собранная из кода: мир пересчитывается один раз на весь модуль.""" """Паспорт, собранный из кода: мир пересчитывается один раз на весь модуль."""
return build() return build()
def test_inventory_is_up_to_date(inventory: dict): def test_inventory_is_up_to_date(inventory: dict):
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8")) stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
assert stored == inventory, ( assert stored == inventory, (
"опись мира отстала от кода — пересоберите: make inventory." "паспорт мира отстал от кода — пересоберите: make inventory."
" Разошлись хеши дней и каталога — правили data/catalog/products.csv;" " Разошлись хеши дней и каталога — правили data/catalog/products.csv;"
" разошлись только дни — правили генератор" " разошлись только дни — правили генератор"
) )
@@ -68,7 +68,7 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm
"""У каждого отправленного слепка — своя строка с хешем его байтов. """У каждого отправленного слепка — своя строка с хешем его байтов.
Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков: это Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков: это
второй артефакт мира, и побайтовое обещание сторожит опись. Слепков на день второй артефакт мира, и побайтовое обещание сторожит паспорт. Слепков на день
меньше, чем дней: прогон дня 0 не отправляет ничего. меньше, чем дней: прогон дня 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): def test_a_replayed_day_matches_its_inventory_hash(tmp_path):
"""Опись сверяется с байтами, вышедшими из CLI, а не с собранной тут же: """Паспорт сверяется с байтами, вышедшими из CLI, а не с собранной тут же:
пересчет сравнивался бы сам с собой. пересчет сравнивался бы сам с собой.
""" """
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8")) stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
+1 -1
View File
@@ -28,7 +28,7 @@ def test_every_event_carries_every_column(events):
«Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший «Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший
ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть
канона: от него зависят байты, а значит и хеши описи. канона: от него зависят байты, а значит и хеши паспорта.
""" """
names = [column.name for column in schema.COLUMNS] names = [column.name for column in schema.COLUMNS]
for event in events: for event in events:
+1 -1
View File
@@ -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]): 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)] numbers = [record["order_id"] for record in snapshot_records(days, FULL_WINDOW_DAY)]
@@ -104,7 +104,7 @@
}, },
{ {
"type": "timeseries", "type": "timeseries",
"title": "Поток: сколько строк приезжает в слой сырья", "title": "Поток: сколько строк приезжает в сырой слой",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
+7 -7
View File
@@ -102,12 +102,12 @@ on_signal() {
# Единственный постоянный сторож цепочки Kafka → STG → ODS. # Единственный постоянный сторож цепочки Kafka → STG → ODS.
# #
# Проверка спрашивает не «есть ли данные», а «те ли это данные»: подневный счёт # Проверка спрашивает не «есть ли данные», а «те ли это данные»: подневный счёт
# в `ods.event` против описи мира, лежащей в git. Поэтому она умеет краснеть на # в `ods.event` против паспорта мира, лежащего в git. Поэтому она умеет краснеть на
# сломанном разборе — уехали события в брак, счёт разошёлся, — и на потере по # сломанном разборе — уехали события в брак, счёт разошёлся, — и на потере по
# дороге, а не только на пустой таблице. # дороге, а не только на пустой таблице.
# #
# **Счёт обрамлён датами стартового мира.** Мир растёт: менти переиграет день, # **Счёт обрамлён датами стартового мира.** Мир растёт: менти переиграет день,
# этап 5 добавит следующий, — и голый счёт по таблице разойдётся с описью # этап 5 добавит следующий, — и голый счёт по таблице разойдётся с паспортом
# законно, без всякой поломки. # законно, без всякой поломки.
# #
# **Счёт идёт через ods.event_v** — каноническую поверхность актуального # **Счёт идёт через ods.event_v** — каноническую поверхность актуального
@@ -138,7 +138,7 @@ check_starting_world() {
FORMAT TSV")" FORMAT TSV")"
if [[ "$actual" == "$expected" ]]; then if [[ "$actual" == "$expected" ]]; then
printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с описью.\n' printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с паспортом.\n'
return return
fi fi
@@ -148,13 +148,13 @@ check_starting_world() {
GROUP BY error_class GROUP BY error_class
ORDER BY count() DESC ORDER BY count() DESC
FORMAT TSV")" FORMAT TSV")"
printf 'Опись мира ожидает (дата, событий):\n%s\n' "$expected" >&2 printf 'Паспорт мира ожидает (дата, событий):\n%s\n' "$expected" >&2
printf 'В ods.event лежит:\n%s\n' "${actual:-— ничего —}" >&2 printf 'В ods.event лежит:\n%s\n' "${actual:-— ничего —}" >&2
if [[ -z "$broken" ]]; then 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 fi
printf 'В ods.event_errors по классам брака:\n%s\n' "$broken" >&2 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() { 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")" "словарь товаров не ответил на второй ноде" assert_equal "$expected_product" "$(query_as_etl clickhouse-02 "$product_sql")" "словарь товаров не ответил на второй ноде"
printf 'ЗЕЛЁНО: известный sku вернул имя, категорию и цену на обеих нодах.\n' printf 'ЗЕЛЁНО: известный sku вернул имя, категорию и цену на обеих нодах.\n'
printf 'Проверка 10/10: стартовый мир в ods.event сходится с описью...\n' printf 'Проверка 10/10: стартовый мир в ods.event сходится с паспортом...\n'
check_starting_world check_starting_world
printf 'ИТОГ: все 10 проверок кластера ClickHouse прошли.\n' printf 'ИТОГ: все 10 проверок кластера ClickHouse прошли.\n'
+7 -7
View File
@@ -1,6 +1,6 @@
-- STG: чтецы топиков hits и orders, таблицы сырья обоих источников. -- STG: чтецы топиков hits и orders, таблицы STG обоих источников.
-- --
-- Слой сырья ничего не интерпретирует: сообщение ложится строкой, как пришло, -- Сырой слой ничего не интерпретирует: сообщение ложится строкой, как пришло,
-- рядом с метаданными доставки. Довод целиком — ADR 0005, конвенции колонок и -- рядом с метаданными доставки. Довод целиком — ADR 0005, конвенции колонок и
-- сроков — docs/architecture/storage.md. -- сроков — docs/architecture/storage.md.
@@ -30,9 +30,9 @@ SETTINGS
kafka_group_name = 'clickstream_hits', kafka_group_name = 'clickstream_hits',
kafka_format = 'RawBLOB'; kafka_format = 'RawBLOB';
-- Локальная таблица сырья. -- Локальная таблица STG.
-- --
-- Движок именно ReplicatedMergeTree, не Replacing: повтор доставки в сырье -- Движок именно ReplicatedMergeTree, не Replacing: повтор доставки в STG
-- обязан быть виден — ради этого слой и заведён. -- обязан быть виден — ради этого слой и заведён.
-- --
-- Служебные колонки не повторяют имён виртуальных (_topic, _partition, -- Служебные колонки не повторяют имён виртуальных (_topic, _partition,
@@ -45,7 +45,7 @@ SETTINGS
-- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа -- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа
-- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms — -- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms —
-- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная, -- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная,
-- _load_ts рядом тоже миллисекундная, а слой сырья хранит то, что приехало, и -- _load_ts рядом тоже миллисекундная, а сырой слой хранит то, что приехало, и
-- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не -- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не
-- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год. -- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год.
-- --
@@ -115,8 +115,8 @@ SETTINGS
kafka_format = 'RawBLOB', kafka_format = 'RawBLOB',
kafka_commit_on_select = 1; kafka_commit_on_select = 1;
-- Локальная таблица сырья заказов. Колонки, типы, ключ, нарезка и срок жизни — -- Локальная таблица STG заказов. Колонки, типы, ключ, нарезка и срок жизни —
-- те же, что у сырья событий, и по тем же доводам: docs/architecture/storage.md. -- те же, что у STG событий, и по тем же доводам: docs/architecture/storage.md.
-- --
-- Своя колонка здесь одна — _load_id, идентификатор пачки загрузки: он равен -- Своя колонка здесь одна — _load_id, идентификатор пачки загрузки: он равен
-- run_id прогона Airflow, который забрал порцию, и по нему разбор в ODS читает -- run_id прогона Airflow, который забрал порцию, и по нему разбор в ODS читает
+3 -3
View File
@@ -106,7 +106,7 @@ SAMPLE BY intHash32(ClientID);
-- События одной куки при этом остаются на одном шарде, и сессионизация со -- События одной куки при этом остаются на одном шарде, и сессионизация со
-- склейкой идентичностей ниже по течению живут локально. -- склейкой идентичностей ниже по течению живут локально.
-- --
-- У сырья ключ другой (хеш строки), поэтому сырая строка и разобранное из неё -- У STG ключ другой (хеш строки), поэтому сырая строка и разобранное из неё
-- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв -- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв
-- сходиться не должны — сверять слои можно только через _dist. -- сходиться не должны — сверять слои можно только через _dist.
CREATE TABLE IF NOT EXISTS ods.event_dist ON CLUSTER clickstream_cluster 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 у неразобранной -- Ключи собственные. Шардирование — cityHash64(raw): ClientID у неразобранной
-- строки взять неоткуда. ORDER BY (error_class, kafka_partition, -- строки взять неоткуда. ORDER BY (error_class, kafka_partition,
-- kafka_offset): такую таблицу смотрят от класса брака, а внутри класса — по -- kafka_offset): такую таблицу смотрят от класса брака, а внутри класса — по
-- координатам доставки. Нарезка по дню загрузки, как у сырья: модельного дня -- координатам доставки. Нарезка по дню загрузки, как у STG: модельного дня
-- у брака тоже нет. -- у брака тоже нет.
-- --
-- Срок жизни — месяц, вдесятеро дольше сырья. Истеки брак вместе с сырьём — -- Срок жизни — месяц, вдесятеро дольше сырых данных. Истеки брак вместе с ними —
-- к моменту разбирательства не осталось бы ни того, ни другого. -- к моменту разбирательства не осталось бы ни того, ни другого.
CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
( (
+2 -2
View File
@@ -25,7 +25,7 @@ AS
SELECT * SELECT *
FROM ods.order_dist FINAL; FROM ods.order_dist FINAL;
-- ODS: разбор сырья в событие и в таблицу ошибок. -- ODS: разбор STG в событие и в таблицу ошибок.
-- --
-- Матвью две, и вместе они обязаны делить поток без зазора и без нахлёста: -- Матвью две, и вместе они обязаны делить поток без зазора и без нахлёста:
-- строка, подошедшая обеим, задвоится, а не подошедшая ни одной — исчезнет -- строка, подошедшая обеим, задвоится, а не подошедшая ни одной — исчезнет
@@ -168,7 +168,7 @@ SELECT
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity, JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType, JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce, JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
-- Метка загрузки переносится из сырья как есть, а не ставится заново: -- Метка загрузки переносится из STG как есть, а не ставится заново:
-- она отвечает на вопрос «когда строка приехала в хранилище». Поставь -- она отвечает на вопрос «когда строка приехала в хранилище». Поставь
-- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос. -- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос.
_load_ts _load_ts
+2 -2
View File
@@ -1,10 +1,10 @@
-- STG: матвью приёма — из чтеца в сырьё. -- STG: матвью приёма — из чтеца в таблицу STG.
-- --
-- Номер 40, а не 11, и пропуск в нумерации намеренный. Файлы 20-ods-tables.sql -- Номер 40, а не 11, и пропуск в нумерации намеренный. Файлы 20-ods-tables.sql
-- и 30-ods-views.sql приносит #43, и матвью приёма обязана создаваться после -- и 30-ods-views.sql приносит #43, и матвью приёма обязана создаваться после
-- матвью разбора: Kafka-движок начинает читать топик ровно тогда, когда к нему -- матвью разбора: Kafka-движок начинает читать топик ровно тогда, когда к нему
-- привязывают первую матвью. Создай её раньше разбора — и всё, что доедет в -- привязывают первую матвью. Создай её раньше разбора — и всё, что доедет в
-- зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. -- зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в ошибки.
-- На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет: -- На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет:
-- проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, то есть на -- проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, то есть на
-- обычной отладке. Нумерацию здесь не «приводить в порядок». -- обычной отладке. Нумерацию здесь не «приводить в порядок».
+1 -1
View File
@@ -17,7 +17,7 @@
-- Позиция — номер первого несыгранного дня, счётчик прожитого. Своей копии -- Позиция — номер первого несыгранного дня, счётчик прожитого. Своей копии
-- начала оси стенд из-за неё не заводит: начало он спрашивает у данных, и -- начала оси стенд из-за неё не заводит: начало он спрашивает у данных, и
-- дата дня N в них — константа, начало оси плюс N. Держится якорь на том, что -- дата дня N в них — константа, начало оси плюс N. Держится якорь на том, что
-- ранние дни из ods.event не исчезают: срок жизни назначен сырью и таблицам -- ранние дни из ods.event не исчезают: срок жизни назначен сырым данным и таблицам
-- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql). -- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql).
-- --
-- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма -- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма
+2 -2
View File
@@ -1,8 +1,8 @@
-- DQ: стендовая сторона технической растяжки от описи мира. -- DQ: стендовая сторона технической растяжки от паспорта мира.
-- --
-- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего -- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего
-- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону; -- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону;
-- Python-задача сопоставляет ее с пятью счетчиками закрытого дня из описи. -- Python-задача сопоставляет ее с пятью счетчиками закрытого дня из паспорта.
SELECT SELECT
order_day AS data_date, order_day AS data_date,
mismatch_class, mismatch_class,
+1 -1
View File
@@ -124,7 +124,7 @@ SELECT
_load_ts _load_ts
FROM stg.orders_raw_dist FROM stg.orders_raw_dist
WHERE _load_id = {load_id:String} AND NOT row_is_valid WHERE _load_id = {load_id:String} AND NOT row_is_valid
-- Тот же ноль: ключ брака сегодня совпадает с ключом сырья, и локальная -- Тот же ноль: ключ брака сегодня совпадает с ключом STG, и локальная
-- запись легла бы верно — но по совпадению, а не по контракту. -- запись легла бы верно — но по совпадению, а не по контракту.
SETTINGS distributed_foreground_insert = 1, SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0; parallel_distributed_insert_select = 0;
+1 -1
View File
@@ -1,4 +1,4 @@
-- Забор порции слепка заказов из Kafka в сырьё STG. -- Забор порции слепка заказов из Kafka в STG.
-- --
-- Забор — один прямой SELECT, без цикла до пустоты: одна порция ClickHouse -- Забор — один прямой SELECT, без цикла до пустоты: одна порция ClickHouse
-- берёт десятки тысяч сообщений, а слепок дня — порядка полутора тысяч строк. -- берёт десятки тысяч сообщений, а слепок дня — порядка полутора тысяч строк.