diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d4c6094
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,173 @@
+# =============================================================================
+# Python
+# =============================================================================
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# =============================================================================
+# Виртуальное окружение
+# =============================================================================
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+.python-version
+
+# =============================================================================
+# IDE и редакторы
+# =============================================================================
+# VS Code
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# PyCharm / IntelliJ
+.idea/
+*.iml
+*.iws
+*.ipr
+out/
+
+# Jupyter
+.ipynb_checkpoints
+*.ipynb
+
+# =============================================================================
+# Docker
+# =============================================================================
+.dockerignore
+Dockerfile.*.local
+*.env.local
+*.env.*.local
+
+# Docker volumes (local data)
+clickhouse-data/
+kafka-data/
+postgres-data/
+redis-data/
+airflow-data/
+
+# =============================================================================
+# Airflow
+# =============================================================================
+airflow/logs/
+airflow/airflow.db
+airflow/airflow.cfg
+airflow/webserver_config.py
+airflow/*.pid
+airflow/*.err
+airflow/*.out
+airflow/standalone_admin_password.txt
+
+# =============================================================================
+# ClickHouse
+# =============================================================================
+clickhouse-server.log
+clickhouse-server.err.log
+clickhouse-client.log
+*.mergetree*
+
+# =============================================================================
+# Kafka / Zookeeper
+# =============================================================================
+*.log
+kafka-logs/
+zookeeper-logs/
+
+# =============================================================================
+# Superset
+# =============================================================================
+superset/superset.db
+superset/*.db
+superset/static/
+
+# =============================================================================
+# Логи и временные файлы
+# =============================================================================
+logs/
+*.log
+*.tmp
+*.temp
+*.bak
+*.swp
+*.swo
+*~
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# =============================================================================
+# Тестовые данные и артефакты
+# =============================================================================
+# Файлы с данными (но не папки data/ — там исходные данные проекта)
+*.csv.gz
+*.jsonl.gz
+*.parquet
+*.avro
+*.orc
+
+# Архивы
+*.zip
+*.tar.gz
+*.tgz
+*.rar
+
+# =============================================================================
+# Секреты и конфиденциальные данные
+# =============================================================================
+*.pem
+*.key
+*.crt
+secrets/
+credentials/
+config.local.yml
+config.local.yaml
+
+# =============================================================================
+# OS-специфичные файлы
+# =============================================================================
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
+._*
+
+# Linux
+*~
+.nfs*
+
+# Windows
+Thumbs.db
+ehthumbs.db
+Desktop.ini
+$RECYCLE.BIN/
+*.cab
+*.msi
+*.msm
+*.msp
+*.lnk
diff --git a/AGENTS.md b/AGENTS.md
index f479d88..d03e985 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,7 +20,11 @@
## Ключевые артефакты
### Исполняемые файлы (текущая структура)
-- `dags/` — Airflow DAGs для оркестрации ETL
+- `dags/` — Airflow DAGs для оркестрации ETL:
+ - `dags/ddl_init_dag.py` — инициализация схемы ClickHouse
+ - `dags/etl_pipeline_dag.py` — ETL процесс STG → ODS → DDS → DM
+ - `dags/kafka_load_dag.py` — загрузка данных в Kafka из JSONL
+ - `dags/utils/kafka_helpers.py` — helper-функции для работы с Kafka
- `sql/` — SQL по слоям:
- `sql/ddl/00_databases.sql` — создание БД stg/ods/dds/dm
- `sql/ddl/stg/10_stg.sql` — STG слой (Kafka Engine + MV)
@@ -92,13 +96,62 @@
- Bash: шапка с назначением/запуском/требованиями, секции разделены `# -----`
- См. существующие файлы как пример (`sql/ddl/ods/20_ods.sql`, `sql/dds/30_ods_to_dds.sql`, `scripts/run_batch.sh`)
+## Airflow DAGs
+
+### `ddl_init` — Инициализация схемы
+- Запуск: ручной (Trigger DAG)
+- Параметры: `verify_only` (bool, default false)
+- Описание: Создаёт БД и таблицы в ClickHouse от 00_databases до 40_dm
+
+### `kafka_load` — Загрузка в Kafka
+- Запуск: ручный (Trigger DAG with config)
+- Параметры:
+ - `limit` (int, default 0) — количество строк (0 = все)
+ - `reset_topics` (bool, default true) — пересоздать топики
+- Примеры запуска:
+ ```json
+ // Полная загрузка (по умолчанию)
+ {}
+ // Ограниченная загрузка — 100 строк
+ {"limit": 100}
+ ```
+
+### `etl_pipeline` — ETL процесс
+- Запуск: ручной (Trigger DAG with config)
+- Параметры:
+ - `full_refresh` (bool, default true) — очистить DDS перед загрузкой
+- Зависимость: требует наличия данных в STG (от `kafka_load` или `make data`)
+
## Быстрые проверки
- Kafka ingest: наличие данных в `stg.*` и типизированных строк в `ods.*`.
- Мониторинг: доступность `/metrics` у ClickHouse и скрейп в Prometheus.
-- **Airflow: `http://localhost:8080` должен показывать UI и DAG `ddl_init` и `etl_pipeline`.**
+- **Airflow: `http://localhost:8080` должен показывать UI и DAG `ddl_init`, `kafka_load` и `etl_pipeline`.**
- BI: витрина `dm.v_events_enriched` должна отвечать за разумное время при фильтре по дате.
+## Сценарий работы с Airflow (фаза 2)
+
+```bash
+# 1. Запуск инфраструктуры
+make up
+
+# 2. Инициализация схемы (один раз)
+# Airflow UI → DAGs → ddl_init → Trigger DAG
+
+# 3. Загрузка данных через Airflow (вместо make data)
+# Airflow UI → DAGs → kafka_load → Trigger DAG with config
+# Параметры по умолчанию: limit=0 (полная загрузка), reset_topics=true
+
+# 4. Запуск ETL
+# Airflow UI → DAGs → etl_pipeline → Trigger DAG with config
+# {"full_refresh": true}
+
+# 5. Проверка результатов
+docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT count() FROM ods.browser_event"
+docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT count() FROM dds.event"
+docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT * FROM dm.dq_summary"
+```
+
## Связанная документация
- [README.md](./README.md) — пользовательская документация (быстрый старт, архитектура)
diff --git a/README.md b/README.md
index af8e27e..83a1ebf 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
Фокус проекта: быстро показать работающий end-to-end сценарий и понятным языком объяснить, как устроены слои и почему пайплайн не падает на "грязных" данных.
Коротко про поток:
-`data/*.jsonl` -> Kafka (1 строка = 1 сообщение) -> ClickHouse `stg` (сырые JSON) -> Airflow batch `stg -> ods -> dds -> dm` -> Superset.
+`data/*.jsonl` -> Airflow DAG `kafka_load` -> Kafka (1 строка = 1 сообщение) -> ClickHouse `stg` (сырые JSON) -> Airflow DAG `etl_pipeline` (`stg -> ods -> dds -> dm`) -> Superset.
---
@@ -33,10 +33,15 @@ docker compose ps
docker compose exec -T airflow-webserver airflow dags trigger ddl_init
```
-Загрузка небольшого среза данных в Kafka:
+Загрузка данных в Kafka через Airflow DAG:
```bash
-make data # по умолчанию первые 50 строк
-# или: FULL=1 make data # полный датасет (1000 строк)
+# Полная загрузка (по умолчанию limit=0)
+docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
+ --conf '{"reset_topics": true}'
+
+# Ограниченная загрузка — первые 100 строк
+docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
+ --conf '{"limit": 100, "reset_topics": true}'
```
Запуск batch-трансформации (STG -> ODS -> DDS -> DM) в Airflow (если DAG выключен, сначала unpause):
@@ -75,31 +80,29 @@ docker compose exec -T clickhouse clickhouse-client --user=default --password=12
## Архитектура (в двух словах)
```mermaid
-flowchart TB
- subgraph Sources["JSONL файлы"]
- BE[browser_events.jsonl]
- LE[location_events.jsonl]
- DE[device_events.jsonl]
- GE[geo_events.jsonl]
+flowchart LR
+ subgraph AF["Airflow"]
+ D1["ddl_init"]
+ D2["kafka_load"]
+ D3["etl_pipeline"]
end
subgraph Kafka["Kafka"]
- KT[Топики]
+ Topics[4 топика]
end
subgraph CH["ClickHouse"]
- STG["stg: сырьё + Kafka MV"]
- ODS["ods: типизация + DQ"]
- DDS["dds: сущности"]
- DM["dm: витрины (VIEW)"]
+ STG["STG: сырые данные"]
+ ODS["ODS: типизация + DQ"]
+ DDS["DDS: сущности"]
+ DM["DM: витрины VIEW"]
end
- subgraph Airflow["Airflow"]
- DAG[DAG: ddl_init / etl_pipeline]
- end
+ D2 -->|загрузка JSONL| Kafka -->|Kafka MV| STG
+ STG -->|batch| ODS -->|batch| DDS -->|VIEW| DM
- Sources -->|make data| Kafka -->|MV| STG -->|Batch SQL| ODS -->|Batch SQL| DDS -->|VIEW| DM
- DAG -.->|оркестрация| STG & ODS & DDS & DM
+ D1 -.->|DDL| CH
+ D3 -.->|batch| ODS & DDS
```
Особенность задания про "грязные данные": парсинг не валит pipeline, ошибки фиксируются в `ods.*_errors` и в поле `parse_errors`.
@@ -123,14 +126,14 @@ flowchart TB
│ ├── ods/ # Batch SQL: STG -> ODS
│ ├── dds/ # Batch SQL: ODS -> DDS
│ └── dm/ # Batch SQL: DDS -> DM
-├── scripts/ # Автоматизация (apply ddl, load data, run batch)
+├── scripts/ # Служебные shell-скрипты (legacy fallback, не основной путь)
├── airflow/ # Конфигурация Airflow
│ └── requirements.txt
├── docs/ # Документация
│ └── ARCHITECTURE.md # Подробное описание слоёв
├── data/ # Исходные JSONL файлы
├── docker-compose.yml
-└── Makefile # Команды: up, ddl, data, transform
+└── Makefile # Команды: up, ddl, transform
```
---
@@ -141,8 +144,6 @@ flowchart TB
|---------|----------|
| `make up` | Поднять инфраструктуру |
| `make ddl` | Применить DDL в ClickHouse (вне Airflow) |
-| `make data` | Загрузить данные в Kafka (50 строк) |
-| `FULL=1 make data` | Загрузить полный датасет |
| `make transform` | Запустить batch-процесс `STG -> ODS -> DDS -> DM` (вне Airflow) |
Примечания про сохранность данных:
@@ -206,7 +207,7 @@ flowchart LR
## Частые проблемы
- `etl_pipeline` падает с сообщением про схему: сначала запустите `ddl_init`.
-- После `docker compose down -v` схема и данные исчезнут: нужно заново `ddl_init` и `make data`.
+- После `docker compose down -v` схема и данные исчезнут: нужно заново запустить `ddl_init`, затем `kafka_load`, затем `etl_pipeline`.
- Подключения используют разные протоколы:
- Airflow (ClickHouseOperator) ходит в ClickHouse по native TCP (порт `9000` внутри сети Docker).
- Superset (clickhouse-connect) ходит по HTTP (порт `8123` внутри сети Docker).
@@ -215,13 +216,13 @@ flowchart LR
## Статус проекта
-Реализовано (Этап 1):
+Реализовано:
- DAG `ddl_init`: последовательное применение DDL + проверка схемы.
+- DAG `kafka_load`: ingest из `.jsonl` в Kafka через `kafka-python` (параметры `limit`, `reset_topics`).
- DAG `etl_pipeline`: precheck, ожидание данных в STG, batch-пересчёт ODS/DDS/DM, базовые проверки.
- Устойчивость к "грязным" данным: ошибки парсинга сохраняются в ODS, а не валят ingest.
В планах (не требуется для MVP задания):
-- DAG `kafka_load` (чистый ingest из `.jsonl` в Kafka средствами Airflow).
- Инкрементальный batch (watermark вместо `full_refresh`).
- DQ мониторинг по расписанию.
diff --git a/airflow/requirements.txt b/airflow/requirements.txt
index feae4fb..9504b3c 100644
--- a/airflow/requirements.txt
+++ b/airflow/requirements.txt
@@ -5,3 +5,6 @@ psycopg2-binary==2.9.9
# ClickHouse operator/hook для DAG'ов
airflow-clickhouse-plugin==1.6.0
+
+# Kafka client для загрузки данных (DAG kafka_load)
+kafka-python==2.0.6
diff --git a/dags/kafka_load_dag.py b/dags/kafka_load_dag.py
new file mode 100644
index 0000000..484f8c8
--- /dev/null
+++ b/dags/kafka_load_dag.py
@@ -0,0 +1,234 @@
+"""
+DAG для загрузки данных в Kafka из JSONL-файлов.
+
+Функциональность:
+- Проверка доступности Kafka и наличия файлов
+- Создание/сброс топиков
+- Параллельная загрузка 4 потоков данных
+- Проверка результатов через XCom
+
+Параметры (через Trigger DAG with config):
+ limit (int): Количество строк для загрузки (по умолчанию 0 = все)
+ reset_topics (bool): Пересоздать топики (по умолчанию True)
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from pathlib import Path
+
+from airflow import DAG
+from airflow.exceptions import AirflowException
+from airflow.models.param import Param
+from airflow.operators.python import PythonOperator
+from airflow.utils.task_group import TaskGroup
+
+# Импортируем helper-функции
+from utils.kafka_helpers import (
+ check_input_files,
+ check_kafka_ready,
+ load_jsonl,
+ prepare_topics,
+ validate_load_params,
+)
+
+# -----------------------------------------------------------------------------
+# Базовые настройки DAG
+# -----------------------------------------------------------------------------
+default_args = {
+ "owner": "airflow",
+ "depends_on_past": False,
+ "email_on_failure": False,
+ "email_on_retry": False,
+ "retries": 1,
+ "retry_delay": timedelta(minutes=2),
+}
+
+DATA_DIR = Path("/opt/airflow/data")
+
+# -----------------------------------------------------------------------------
+# Python callable функции для задач
+# -----------------------------------------------------------------------------
+def _check_kafka(**context) -> None:
+ """Проверка доступности Kafka брокера."""
+ check_kafka_ready()
+
+
+def _check_files(**context) -> None:
+ """Проверка наличия входных файлов."""
+ check_input_files(data_dir=DATA_DIR)
+
+
+def _validate_params(**context) -> None:
+ """Валидация параметров загрузки."""
+ conf = context.get("dag_run", {}).conf or {}
+ limit = int(conf.get("limit", context["params"]["limit"]))
+ validate_load_params(limit=limit)
+
+
+def _prepare_topics(**context) -> None:
+ """Подготовка топиков Kafka (создание/сброс)."""
+ conf = context.get("dag_run", {}).conf or {}
+ reset_topics = bool(conf.get("reset_topics", context["params"]["reset_topics"]))
+
+ prepare_topics(reset=reset_topics)
+
+
+def _load_events(event_type: str, **context) -> int:
+ """
+ Загружает события определённого типа в Kafka.
+
+ Args:
+ event_type: Тип события (browser, location, device, geo)
+
+ Returns:
+ Количество отправленных сообщений
+ """
+ conf = context.get("dag_run", {}).conf or {}
+ limit = int(conf.get("limit", context["params"]["limit"]))
+
+ # Маппинг типа события на топик и файл
+ event_mapping = {
+ "browser": ("browser_events", "browser_events.jsonl"),
+ "location": ("location_events", "location_events.jsonl"),
+ "device": ("device_events", "device_events.jsonl"),
+ "geo": ("geo_events", "geo_events.jsonl"),
+ }
+
+ topic, filename = event_mapping[event_type]
+ file_path = DATA_DIR / filename
+
+ # Загружаем данные
+ sent_count = load_jsonl(
+ file_path=file_path,
+ topic=topic,
+ limit=limit,
+ )
+
+ # Сохраняем результат в XCom для verify_publish_counts
+ context["ti"].xcom_push(key=f"{event_type}_count", value=sent_count)
+
+ return sent_count
+
+
+def _verify_counts(**context) -> None:
+ """Проверяет, что все загрузки отправили сообщения."""
+ ti = context["ti"]
+
+ # Собираем результаты из XCom
+ results = {
+ "browser_events": ti.xcom_pull(task_ids="ingest.load_browser_events", key="browser_count") or 0,
+ "location_events": ti.xcom_pull(task_ids="ingest.load_location_events", key="location_count") or 0,
+ "device_events": ti.xcom_pull(task_ids="ingest.load_device_events", key="device_count") or 0,
+ "geo_events": ti.xcom_pull(task_ids="ingest.load_geo_events", key="geo_count") or 0,
+ }
+ total_sent = sum(results.values())
+
+ # Проверяем, что отправлено хотя бы что-то
+ if total_sent == 0:
+ raise AirflowException("Не отправлено ни одного сообщения ни в один топик")
+
+ # Логируем итоговую статистику
+ for topic, count in results.items():
+ print(f"✓ {topic}: {count} сообщений")
+ print(f"\nВсего отправлено: {total_sent} сообщений")
+
+
+# -----------------------------------------------------------------------------
+# Определение DAG
+# -----------------------------------------------------------------------------
+with DAG(
+ dag_id="kafka_load",
+ default_args=default_args,
+ description="Загрузка данных из JSONL в Kafka топики",
+ schedule=None, # Только ручной запуск
+ start_date=datetime(2024, 1, 1),
+ catchup=False,
+ max_active_runs=1,
+ is_paused_upon_creation=True,
+ tags=["kafka", "ingest", "experiments"],
+ params={
+ "limit": Param(
+ default=0,
+ type="integer",
+ description="Количество строк для загрузки (0 = все строки, по умолчанию)",
+ ),
+ "reset_topics": Param(
+ default=True,
+ type="boolean",
+ description="Пересоздать топики перед загрузкой",
+ ),
+ },
+) as dag:
+
+ # -------------------------------------------------------------------------
+ # TaskGroup: precheck — проверки перед загрузкой
+ # -------------------------------------------------------------------------
+ with TaskGroup(group_id="precheck") as precheck:
+ check_kafka = PythonOperator(
+ task_id="check_kafka",
+ python_callable=_check_kafka,
+ )
+
+ check_input_files_task = PythonOperator(
+ task_id="check_input_files",
+ python_callable=_check_files,
+ )
+
+ validate_params = PythonOperator(
+ task_id="validate_load_params",
+ python_callable=_validate_params,
+ )
+
+ check_kafka >> check_input_files_task >> validate_params
+
+ # -------------------------------------------------------------------------
+ # TaskGroup: ingest — загрузка данных
+ # -------------------------------------------------------------------------
+ with TaskGroup(group_id="ingest") as ingest:
+ prepare_topics_task = PythonOperator(
+ task_id="prepare_topics",
+ python_callable=_prepare_topics,
+ )
+
+ load_browser_events = PythonOperator(
+ task_id="load_browser_events",
+ python_callable=_load_events,
+ op_kwargs={"event_type": "browser"},
+ )
+
+ load_location_events = PythonOperator(
+ task_id="load_location_events",
+ python_callable=_load_events,
+ op_kwargs={"event_type": "location"},
+ )
+
+ load_device_events = PythonOperator(
+ task_id="load_device_events",
+ python_callable=_load_events,
+ op_kwargs={"event_type": "device"},
+ )
+
+ load_geo_events = PythonOperator(
+ task_id="load_geo_events",
+ python_callable=_load_events,
+ op_kwargs={"event_type": "geo"},
+ )
+
+ verify_publish_counts = PythonOperator(
+ task_id="verify_publish_counts",
+ python_callable=_verify_counts,
+ )
+
+ # Зависимости: подготовка -> параллельная загрузка -> проверка
+ prepare_topics_task >> [
+ load_browser_events,
+ load_location_events,
+ load_device_events,
+ load_geo_events,
+ ] >> verify_publish_counts
+
+ # -------------------------------------------------------------------------
+ # Итоговая цепочка
+ # -------------------------------------------------------------------------
+ precheck >> ingest
diff --git a/dags/utils/__init__.py b/dags/utils/__init__.py
new file mode 100644
index 0000000..29a1ee3
--- /dev/null
+++ b/dags/utils/__init__.py
@@ -0,0 +1 @@
+# Utils package для Airflow DAG'ов
diff --git a/dags/utils/kafka_helpers.py b/dags/utils/kafka_helpers.py
new file mode 100644
index 0000000..ac142ca
--- /dev/null
+++ b/dags/utils/kafka_helpers.py
@@ -0,0 +1,281 @@
+"""
+Helper-функции для работы с Kafka из Airflow DAG'ов.
+
+Использует kafka-python:
+- KafkaAdminClient — для управления топиками
+- KafkaProducer — для публикации сообщений
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+# -----------------------------------------------------------------------------
+# Конфигурация подключения к Kafka
+# -----------------------------------------------------------------------------
+KAFKA_BOOTSTRAP_SERVERS = "kafka:29092"
+REQUEST_TIMEOUT_MS = 30000
+
+# Топики и соответствующие файлы данных
+TOPIC_FILE_MAP = {
+ "browser_events": "browser_events.jsonl",
+ "location_events": "location_events.jsonl",
+ "device_events": "device_events.jsonl",
+ "geo_events": "geo_events.jsonl",
+}
+
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Проверка доступности Kafka
+# -----------------------------------------------------------------------------
+def check_kafka_ready(
+ bootstrap_servers: str = KAFKA_BOOTSTRAP_SERVERS,
+ timeout_ms: int = REQUEST_TIMEOUT_MS,
+) -> None:
+ """
+ Проверяет доступность Kafka брокера.
+
+ Args:
+ bootstrap_servers: Адрес Kafka брокера
+ timeout_ms: Таймаут запроса в миллисекундах
+
+ Raises:
+ AirflowException: Если Kafka недоступна
+ """
+ from kafka import KafkaAdminClient
+ from kafka.errors import NoBrokersAvailable
+ from airflow.exceptions import AirflowException
+
+ try:
+ admin_client = KafkaAdminClient(
+ bootstrap_servers=bootstrap_servers,
+ request_timeout_ms=timeout_ms,
+ )
+ # Проверяем связь, запрашивая список топиков
+ admin_client.list_topics()
+ admin_client.close()
+ logger.info("Kafka брокер доступен: %s", bootstrap_servers)
+ except NoBrokersAvailable as e:
+ raise AirflowException(f"Kafka брокер недоступен: {bootstrap_servers}") from e
+ except Exception as e:
+ raise AirflowException(f"Ошибка подключения к Kafka: {e}") from e
+
+
+# -----------------------------------------------------------------------------
+# Управление топиками
+# -----------------------------------------------------------------------------
+def prepare_topics(
+ topics: list[str] | None = None,
+ reset: bool = True,
+ bootstrap_servers: str = KAFKA_BOOTSTRAP_SERVERS,
+ timeout_ms: int = REQUEST_TIMEOUT_MS,
+) -> None:
+ """
+ Создаёт или пересоздаёт топики Kafka.
+
+ Args:
+ topics: Список топиков для создания (по умолчанию все из TOPIC_FILE_MAP)
+ reset: Если True — удаляет топики перед созданием
+ bootstrap_servers: Адрес Kafka брокера
+ timeout_ms: Таймаут операций в миллисекундах
+ """
+ from kafka import KafkaAdminClient
+ from kafka.admin import NewTopic
+ from kafka.errors import TopicAlreadyExistsError, UnknownTopicOrPartitionError
+ from airflow.exceptions import AirflowException
+
+ if topics is None:
+ topics = list(TOPIC_FILE_MAP.keys())
+
+ admin_client = KafkaAdminClient(
+ bootstrap_servers=bootstrap_servers,
+ request_timeout_ms=timeout_ms,
+ )
+
+ try:
+ # Удаляем топики если reset=True
+ if reset:
+ try:
+ admin_client.delete_topics(topics, timeout_ms=timeout_ms)
+ logger.info("Удалены топики: %s", topics)
+ except UnknownTopicOrPartitionError:
+ # Топики не существуют — это нормально
+ logger.info("Топики для удаления не найдены (уже отсутствуют)")
+ except Exception as e:
+ logger.warning("Ошибка при удалении топиков: %s", e)
+
+ # Создаём топики
+ new_topics = [
+ NewTopic(
+ name=topic,
+ num_partitions=1, # Дефолтное количество партиций
+ replication_factor=1,
+ )
+ for topic in topics
+ ]
+
+ try:
+ admin_client.create_topics(new_topics, timeout_ms=timeout_ms)
+ logger.info("Созданы топики: %s", topics)
+ except TopicAlreadyExistsError:
+ logger.info("Топики уже существуют: %s", topics)
+ except Exception as e:
+ raise AirflowException(f"Ошибка создания топиков: {e}") from e
+
+ finally:
+ admin_client.close()
+
+
+# -----------------------------------------------------------------------------
+# Загрузка данных из JSONL
+# -----------------------------------------------------------------------------
+def load_jsonl(
+ file_path: str | Path,
+ topic: str,
+ limit: int = 0,
+ bootstrap_servers: str = KAFKA_BOOTSTRAP_SERVERS,
+) -> int:
+ """
+ Читает JSONL-файл и публикует строки в Kafka топик.
+
+ Формат: 1 строка JSON = 1 сообщение (value), без ключа.
+
+ Args:
+ file_path: Путь к .jsonl файлу
+ topic: Имя Kafka топика
+ limit: Максимальное количество строк (0 = все строки)
+ bootstrap_servers: Адрес Kafka брокера
+
+ Returns:
+ Количество отправленных сообщений
+
+ Raises:
+ AirflowException: Если файл не найден или ошибка отправки
+ """
+ from kafka import KafkaProducer
+ from kafka.errors import KafkaError
+ from airflow.exceptions import AirflowException
+
+ file_path = Path(file_path)
+ if not file_path.is_file():
+ raise AirflowException(f"Файл не найден: {file_path}")
+
+ producer = KafkaProducer(
+ bootstrap_servers=bootstrap_servers,
+ # Отправляем сырые байты (строки JSON как есть)
+ value_serializer=lambda v: v.encode("utf-8") if isinstance(v, str) else v,
+ acks="all", # Ждём подтверждения от всех реплик
+ retries=3,
+ batch_size=16384,
+ linger_ms=10,
+ )
+
+ sent_count = 0
+ error_count = 0
+
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ for line_num, line in enumerate(f, 1):
+ # Пропускаем пустые строки
+ line = line.strip()
+ if not line:
+ continue
+
+ # Проверяем лимит
+ if limit > 0 and sent_count >= limit:
+ logger.info(
+ "Достигнут лимит %d строк для %s", limit, topic
+ )
+ break
+
+ # Отправляем сообщение
+ try:
+ future = producer.send(topic, value=line)
+ # Неблокирующая отправка, собираем future для проверки
+ sent_count += 1
+ except KafkaError as e:
+ error_count += 1
+ logger.error("Ошибка отправки строки %d в %s: %s", line_num, topic, e)
+ if error_count > 10:
+ raise AirflowException(
+ f"Слишком много ошибок отправки в {topic}"
+ ) from e
+
+ # Ждём завершения всех отправок
+ producer.flush(timeout=60)
+
+ logger.info(
+ "Загрузка завершена: %s -> %s, отправлено %d сообщений",
+ file_path.name,
+ topic,
+ sent_count,
+ )
+
+ if sent_count == 0:
+ raise AirflowException(f"Не отправлено ни одного сообщения в {topic}")
+
+ return sent_count
+
+ except Exception as e:
+ if isinstance(e, AirflowException):
+ raise
+ raise AirflowException(f"Ошибка загрузки {file_path.name}: {e}") from e
+
+ finally:
+ producer.close(timeout=30)
+
+
+# -----------------------------------------------------------------------------
+# Утилиты для валидации
+# -----------------------------------------------------------------------------
+def validate_load_params(
+ limit: int,
+) -> None:
+ """
+ Валидирует параметры загрузки данных.
+
+ Args:
+ limit: Количество строк для загрузки
+
+ Raises:
+ AirflowException: Если параметры невалидны
+ """
+ from airflow.exceptions import AirflowException
+
+ # Проверка limit
+ if not isinstance(limit, int) or limit < 0:
+ raise AirflowException(f"limit должен быть неотрицательным int, получено: {limit}")
+
+
+def check_input_files(
+ data_dir: str | Path = "/opt/airflow/data",
+) -> None:
+ """
+ Проверяет наличие необходимых JSONL-файлов.
+
+ Args:
+ data_dir: Директория с данными
+
+ Raises:
+ AirflowException: Если какой-либо файл отсутствует
+ """
+ from airflow.exceptions import AirflowException
+
+ data_dir = Path(data_dir)
+ files_to_check = list(TOPIC_FILE_MAP.values())
+
+ missing_files = []
+ for filename in files_to_check:
+ file_path = data_dir / filename
+ if not file_path.is_file():
+ missing_files.append(filename)
+
+ if missing_files:
+ raise AirflowException(
+ f"Отсутствуют файлы данных в {data_dir}: {missing_files}"
+ )
+
+ logger.info("Все необходимые файлы найдены: %s", files_to_check)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e652521..6503cfd 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -25,100 +25,79 @@
```mermaid
flowchart LR
- subgraph Sources["📁 Источники (JSONL)"]
- BE[browser_events]
- LE[location_events]
- DE[device_events]
- GE[geo_events]
+ subgraph AF["Airflow"]
+ DAG1["ddl_init"]
+ DAG2["kafka_load"]
+ DAG3["etl_pipeline"]
end
- subgraph Kafka["🚀 Kafka"]
- K1[browser_events]
- K2[location_events]
- K3[device_events]
- K4[geo_events]
+ subgraph Kafka["Kafka"]
+ K[4 топика]
end
- subgraph STG["📦 STG"]
- S1[browser_raw]
- S2[location_raw]
- S3[device_raw]
- S4[geo_raw]
+ subgraph STG["STG"]
+ S[*_raw таблицы]
end
- subgraph AF["⚙️ Airflow"]
- B1[load_ods
sql/ods/20_stg_to_ods.sql]
- end
-
- subgraph ODS["🔧 ODS"]
- O1[browser_event]
- O2[location_event]
- O3[device_by_click]
- O4[geo_by_click]
+ subgraph ODS["ODS"]
+ O[*_event таблицы]
OE[error_tables]
end
- subgraph DDS["🎯 DDS"]
- DE1[event]
- DC1[click]
+ subgraph DDS["DDS"]
+ D1[event]
+ D2[click]
end
- subgraph DM["📊 DM"]
- DM1[v_events_enriched]
- DM2[v_daily_traffic]
- DM3[v_utm_effectiveness]
- DM4[v_top_pages]
+ subgraph DM["DM"]
+ V[витрины VIEW]
end
- BE --> K1 --> S1
- LE --> K2 --> S2
- DE --> K3 --> S3
- GE --> K4 --> S4
+ DAG2 -->|JSONL| K -->|MV| S
+ S -->|batch| O
+ O -->|argMax + JOIN| D1 & D2
+ O -.->|ошибки| OE
+ D1 & D2 -->|VIEW| V
- S1 & S2 & S3 & S4 -->|Batch SQL| B1
- B1 --> O1 & O2 & O3 & O4
- B1 -.-> OE
- O1 --> DE1
- O2 --> DE1
- O3 --> DC1
- O4 --> DC1
- DE1 --> DM1
- DC1 --> DM1
- DE1 --> DM2 & DM3 & DM4
- DC1 --> DM2 & DM3 & DM4
+ DAG1 -.->|DDL| STG & ODS & DDS & DM
+ DAG3 -.->|batch| ODS & DDS
```
### Слои и их назначение
```mermaid
-flowchart TB
- subgraph L0["📝 Источники"]
- RAW["JSON файлы (1000 строк)"]
+flowchart LR
+ subgraph AF["Airflow"]
+ DAG1["ddl_init"]
+ DAG2["kafka_load"]
+ DAG3["etl_pipeline"]
end
- subgraph L1["📦 STG - Staging"]
- direction LR
+ subgraph L1["STG"]
KAFKA["Kafka Engine"]
- STG_T["*_raw таблицы
(MergeTree)"]
+ STG_T["*_raw таблицы"]
end
- subgraph L2["🔧 ODS - Операционный слой"]
- direction LR
- ODS_T["Типизированные таблицы
(ReplacingMergeTree)"]
- DQ["parse_errors
DQ-метрики"]
+ subgraph L2["ODS"]
+ ODS_T["Типизированные таблицы"]
+ DQ["parse_errors"]
end
- subgraph L3["🎯 DDS - Детальный слой"]
- DDS_T["event + click
(Batch SQL)"]
+ subgraph L3["DDS"]
+ DDS_T["event + click"]
end
- subgraph L4["📊 DM - Витрины"]
- DM_T["VIEW для BI
(Superset/Grafana)"]
+ subgraph L4["DM"]
+ DM_T["VIEW"]
end
- RAW -->|kafka-console-producer| KAFKA -->|MV| STG_T -->|Batch SQL (Airflow)| ODS_T
+ DAG2 -->|JSONL| KAFKA -->|MV| STG_T
+ STG_T -->|batch| ODS_T
ODS_T -->|argMax + JOIN| DDS_T -->|VIEW| DM_T
ODS_T -.->|ошибки| DQ
+
+ DAG1 -.->|DDL| L1 & L2 & L3 & L4
+ DAG3 -.->|batch| ODS_T & DDS_T
```
---
@@ -167,7 +146,7 @@ CREATE TABLE stg.browser_raw (
| `location_event` | event_id | ReplacingMergeTree(src_ingest_ts) | Данные страниц |
| `device_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Устройства |
| `geo_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Гео-данные |
-| `*_errors` | — | MergeTree | Строки с битыми ключами |
+| `*_errors` | — | MergeTree | Строки с критичными ошибками парсинга |
**Batch шаг наполнения ODS:**
@@ -179,8 +158,8 @@ CREATE TABLE stg.browser_raw (
| `INSERT ... SELECT` в `*_errors` | Сохранение строк с критичными ошибками парсинга |
**Логика разделения:**
-- **Основная таблица**: строки с валидными ключами (`WHERE key IS NOT NULL`)
-- **Таблица ошибок**: строки с невалидными ключами (`WHERE key IS NULL`)
+- **Основная таблица**: строки с валидным business key (`WHERE key IS NOT NULL`).
+- **Таблица ошибок**: строки с критичными ошибками (невалидный key, timestamp/координаты и т.п.) через отдельный `INSERT ... SELECT`.
**Пример структуры:**
```sql
@@ -365,7 +344,7 @@ FROM ...
```mermaid
sequenceDiagram
participant User as Пользователь
- participant Make as Makefile
+ participant Compose as Docker Compose
participant Airflow as Airflow
participant K as Kafka
participant CH as ClickHouse
@@ -374,24 +353,24 @@ sequenceDiagram
participant DDS as dds.*
participant DM as dm.*
- User->>Make: make up
- Make->>K: docker compose up kafka
- Make->>CH: docker compose up clickhouse
- K-->>User: ✅ Инфраструктура готова
+ User->>Compose: make up
+ Compose->>K: docker compose up -d kafka
+ Compose->>CH: docker compose up -d clickhouse
+ Compose->>Airflow: docker compose up -d airflow-*
+ Compose-->>User: ✅ Инфраструктура готова
- User->>Make: make ddl
- Make->>CH: sql/ddl/00_databases.sql
- Make->>CH: sql/ddl/stg/10_stg.sql (Kafka Engine)
- Make->>CH: sql/ddl/ods/20_ods.sql (таблицы ODS + drop legacy MV)
- Make->>CH: sql/ddl/dds/30_dds.sql
- Make->>CH: sql/ddl/dm/40_dm.sql
+ User->>Airflow: Trigger ddl_init
+ Airflow->>CH: sql/ddl/00_databases.sql
+ Airflow->>CH: sql/ddl/stg/10_stg.sql (Kafka Engine + MV)
+ Airflow->>CH: sql/ddl/ods/20_ods.sql
+ Airflow->>CH: sql/ddl/dds/30_dds.sql
+ Airflow->>CH: sql/ddl/dm/40_dm.sql
CH-->>User: ✅ Структура БД создана
- User->>Make: make data
- Make->>K: load_kafka_data.sh
- K->>K: Создание топиков
+ User->>Airflow: Trigger kafka_load
+ Airflow->>K: precheck + prepare_topics
loop 4 файла
- Make->>K: kafka-console-producer
+ Airflow->>K: KafkaProducer.send(topic, json_line)
end
K->>CH: Потребление сообщений
CH->>STG: INSERT через MV
@@ -476,8 +455,8 @@ flowchart LR
EV["event"]
end
- B -->|JOIN по event_id| EV
- L -->|JOIN по event_id| EV
+ B -->|JOIN event_id| EV
+ L -->|JOIN event_id| EV
```
**click** (device + geo) с поддержкой partial data:
@@ -489,7 +468,7 @@ flowchart LR
end
subgraph BUILD["Batch SQL"]
- U["UNION DISTINCT
click_id"]
+ U["UNION DISTINCT click_id"]
J["LEFT JOIN"]
end
@@ -535,11 +514,11 @@ flowchart LR
### Обработка ошибок в ODS
-**Проблема:** Грязные данные с невалидными ключами (NULL event_id/click_id).
+**Проблема:** Грязные данные могут содержать не только невалидные ключи, но и невалидные timestamp/координаты/ID.
**Решение:**
-1. **Основная таблица**: только валидные строки (`WHERE key IS NOT NULL`)
-2. **Таблица ошибок**: строки с невалидными ключами через отдельные `INSERT ... SELECT`
+1. **Основная таблица**: строки с валидным business key (`WHERE key IS NOT NULL`)
+2. **Таблица ошибок**: строки с критичными ошибками парсинга через отдельные `INSERT ... SELECT`
3. **DQ-метрики**: массив `parse_errors` для аудита
```sql
@@ -549,7 +528,8 @@ SELECT ... FROM stg.browser_raw WHERE event_id IS NOT NULL;
-- Таблица ошибок
INSERT INTO ods.browser_event_errors
-SELECT ... FROM stg.browser_raw WHERE event_id IS NULL;
+SELECT ... FROM stg.browser_raw
+WHERE event_id IS NULL OR event_ts IS NULL OR click_id IS NULL;
```
### Partial data в DDS
@@ -598,21 +578,30 @@ INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic;
Инфраструктура Airflow развёрнута и готова к использованию:
```python
-# dags/ddl_init_dag.py и dags/etl_pipeline_dag.py
-#
+# dags/ddl_init_dag.py — создание баз/таблиц (ручной запуск при bootstrap)
+# dags/kafka_load_dag.py — загрузка JSONL в Kafka (через kafka-python)
+# dags/etl_pipeline_dag.py — основной ETL (STG→ODS→DDS→DM)
+
# Учебный формат:
# - DDL и трансформации выполняются явными SQL-task через ClickHouseOperator;
# - SQL-файлы вызываются по фиксированным путям;
-# - загрузка данных в Kafka (Этап 1) выполняется через `make data`.
+# - загрузка данных в Kafka выполняется через DAG `kafka_load`.
#
# Основной demo-сценарий:
-# ddl_init -> make data -> etl_pipeline
+# ddl_init -> kafka_load -> etl_pipeline
```
+**DAG `kafka_load`**:
+- Загрузка данных из `data/*.jsonl` в Kafka через `kafka-python`
+- TaskGroup `precheck`: проверка Kafka, файлов, параметров
+- TaskGroup `ingest`: создание топиков → параллельная загрузка 4 потоков → проверка
+- Параметры: `limit` (0 = все), `reset_topics`
+
**Подключение к ClickHouse:**
- Connection: `clickhouse_default`
- URL: `clickhouse://default:123456@clickhouse:9000/default` (native TCP для Airflow plugin)
- Provider/интеграция: `airflow-clickhouse-plugin` (в `airflow/requirements.txt`), задачи выполняются через `ClickHouseOperator`.
+- Дополнительно: `kafka-python==2.0.6` для работы с Kafka из DAG.
- Примечание: Superset подключается к ClickHouse по HTTP (обычно `clickhouse+connect://...:8123/...`).
---
diff --git a/plans/airflow_dags_plan.md b/plans/airflow_dags_plan.md
index b1ecd10..2b69ed5 100644
--- a/plans/airflow_dags_plan.md
+++ b/plans/airflow_dags_plan.md
@@ -61,20 +61,15 @@ check_clickhouse >> ddl_00_databases >> ddl_10_stg >> ddl_20_ods >> ddl_30_dds >
## Дизайн DAG `kafka_load` (отдельный независимый контур)
### Params (через Trigger DAG with config)
-- `limit`: int, default `50`.
-- `full_load`: bool, default `false`.
+- `limit`: int, default `0` (0 = все строки).
- `reset_topics`: bool, default `true`.
-- `load_browser`: bool, default `true`.
-- `load_location`: bool, default `true`.
-- `load_device`: bool, default `true`.
-- `load_geo`: bool, default `true`.
### TaskGroup `precheck`
| Task ID | Что делает | Реализация |
|---------|------------|------------|
| `check_kafka` | Проверка доступности Kafka broker | `PythonOperator` + `kafka-python` |
| `check_input_files` | Проверка наличия `data/*_events.jsonl` | `PythonOperator` |
-| `validate_load_params` | Валидация параметров загрузки (`limit`, `full_load`, флаги потоков) | `PythonOperator` |
+| `validate_load_params` | Валидация параметров загрузки (`limit`) | `PythonOperator` |
### TaskGroup `ingest`
| Task ID | Что делает | Реализация |
@@ -84,7 +79,7 @@ check_clickhouse >> ddl_00_databases >> ddl_10_stg >> ddl_20_ods >> ddl_30_dds >
| `load_location_events` | Публикация `location_events.jsonl` | `PythonOperator` + KafkaProducer |
| `load_device_events` | Публикация `device_events.jsonl` | `PythonOperator` + KafkaProducer |
| `load_geo_events` | Публикация `geo_events.jsonl` | `PythonOperator` + KafkaProducer |
-| `verify_publish_counts` | Проверка, что отправлено > 0 сообщений в выбранные потоки | `PythonOperator` (по XCom) |
+| `verify_publish_counts` | Проверка, что отправлено > 0 сообщений в сумме | `PythonOperator` (по XCom) |
Зависимости:
```text
@@ -136,7 +131,7 @@ ddl_init -> kafka_load -> etl_pipeline
```
Экспериментальные сценарии:
-- `kafka_load` отдельно: проверить разные наборы/параметры загрузки без запуска transform.
+- `kafka_load` отдельно: проверить разные параметры загрузки без запуска transform.
- `etl_pipeline` отдельно: повторно пересчитать DDS/DM по уже загруженным данным.
## Техническая реализация (приземленно)
@@ -157,7 +152,7 @@ ddl_init -> kafka_load -> etl_pipeline
- `fetch_one(sql: str) -> tuple`
- `dags/utils/kafka_helpers.py`:
- `prepare_topics(reset: bool) -> None`
- - `load_jsonl(file_path: str, topic: str, limit: int, full_load: bool) -> int`
+ - `load_jsonl(file_path: str, topic: str, limit: int) -> int`
- `check_kafka_ready() -> None`
## Структура файлов
@@ -182,7 +177,7 @@ dags/
2. Этап 2:
- Реализовать отдельный DAG `kafka_load` на `kafka-python`.
- Перенести загрузку из `make data` в `kafka_load` (функциональный паритет).
- - Добавить в `kafka_load` расширенные параметры экспериментов (выбор потоков, сценарии reset/no-reset).
+ - Добавить в `kafka_load` параметры экспериментов по объёму и reset/no-reset.
- Добавить опциональный параметр автотриггера `etl_pipeline` (по умолчанию `false`).
3. Этап 3:
- Добавить `dq_monitor` и alert callback (email/Slack/webhook).
@@ -226,7 +221,7 @@ docker compose exec -T clickhouse clickhouse-client --user=default --password=12
docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 --query "SELECT count() FROM dm.dq_summary"
# 7) Этап 2: после реализации kafka_load
-# Trigger DAG kafka_load {"limit": 50, "full_load": false, "reset_topics": true}
+# Trigger DAG kafka_load {"limit": 50, "reset_topics": true}
# Trigger DAG etl_pipeline {"full_refresh": true}
```
diff --git a/plans/runbook.md b/plans/runbook.md
index dd3b5d2..d380dae 100644
--- a/plans/runbook.md
+++ b/plans/runbook.md
@@ -1,87 +1,87 @@
-# Runbook: запуск демо и загрузка данных
+# Runbook: Airflow-first запуск демо
-Этот документ фиксирует порядок действий и `make`‑таргеты. Он не описывает внутренности ClickHouse‑слоёв (это в `plans/clickhouse_ddl.md`).
+Документ фиксирует канонический пользовательский сценарий через Airflow DAG'и.
+Внутренности слоёв ClickHouse описаны в `plans/clickhouse_ddl.md` и `docs/ARCHITECTURE.md`.
## Предпосылки
- Docker + Docker Compose.
-- Доступ к Docker daemon (если `docker compose ...` пишет `permission denied ... /var/run/docker.sock`, добавьте пользователя в группу `docker` или запускайте команды с правами, принятыми в вашей среде).
+- Доступ к Docker daemon.
-## Быстрый сценарий
+## Канонический сценарий (через Airflow)
1) Поднять инфраструктуру:
```bash
make up
+docker compose ps
```
-2) (Опционально) Залить данные в Kafka:
+2) Инициализировать схему ClickHouse:
```bash
-make data
+docker compose exec -T airflow-webserver airflow dags trigger ddl_init
```
-`make data` не зависит от ClickHouse/DDL — достаточно, чтобы Kafka была поднята.
-
-3) Применить DDL в ClickHouse:
+3) Загрузить данные в Kafka через DAG `kafka_load`:
```bash
-make ddl
+# Рекомендуется для демо: небольшой срез
+docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
+ --conf '{"limit": 50, "reset_topics": true}'
+
+# Полная загрузка (limit=0 по умолчанию)
+docker compose exec -T airflow-webserver airflow dags trigger kafka_load \
+ --conf '{"reset_topics": true}'
```
-## Make таргеты
-
-- `make up` — `docker compose up -d` (поднимает весь стек из `docker-compose.yml`).
-- `make ddl` — применяет исполняемые SQL-файлы из `sql/ddl/*` в контейнер ClickHouse через `clickhouse-client`.
-- `make data` — пересоздаёт топики (по умолчанию) и публикует события из `data/*.jsonl` в Kafka (1 строка = 1 Kafka message value).
-- `make transform` — выполняет batch-процесс `STG -> ODS -> DDS -> DM` через `scripts/run_batch.sh`.
-
-План реализации механики заливки (дизайн/решения): `plans/kafka_ingest_plan.md`.
-
-## Загрузка данных в Kafka (`make data`)
-
-### Топики
-
-Скрипт использует фиксированный маппинг:
-
-- `data/browser_events.jsonl` → `browser_events`
-- `data/location_events.jsonl` → `location_events`
-- `data/device_events.jsonl` → `device_events`
-- `data/geo_events.jsonl` → `geo_events`
-
-### Режимы загрузки
-
-- По умолчанию — “debug срез”: первые 50 строк каждого файла.
-- Полная загрузка — весь файл.
-
-Параметры (env):
-
-- `LIMIT` — сколько строк брать из каждого `.jsonl`. По умолчанию загружаются все записи (весь файл).
- Для ограничения используйте `LIMIT=50` или `LIMIT=100`.
-- `RESET_TOPICS` — если `RESET_TOPICS=1` (по умолчанию), топики удаляются и создаются заново с теми же именами.
-- `BOOTSTRAP_SERVER` — bootstrap для Kafka *изнутри kafka‑контейнера* (по умолчанию `kafka:29092`).
-
-Примеры:
+4) Запустить ETL:
```bash
-# Загрузить все данные (по умолчанию)
-make data
-
-# Быстрый тест — 50 строк на поток
-LIMIT=50 make data
-
-# Ограниченная загрузка — 100 строк на поток
-LIMIT=100 make data
-
-# Дозалить данные без пересоздания топиков
-RESET_TOPICS=0 make data
+docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline \
+ --conf '{"full_refresh": true}'
```
-## Применение DDL в ClickHouse (`make ddl`)
+5) Проверить результат:
-Скрипт исполняет SQL-файлы из `sql/ddl/*` по фиксированному порядку. Для `ENGINE = Kafka` важно, чтобы `kafka_broker_list` был доступен из контейнера ClickHouse.
+```bash
+docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 \
+ --query "SELECT count() FROM stg.browser_raw"
+docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 \
+ --query "SELECT count() FROM ods.browser_event"
+docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 \
+ --query "SELECT count() FROM dds.event"
+docker compose exec -T clickhouse clickhouse-client --user=default --password=123456 \
+ --query "SELECT * FROM dm.dq_summary ORDER BY layer, table_name, check_name"
+```
-В текущем compose:
+## Параметры DAG'ов
-- для соединений “контейнер → Kafka” используйте `kafka:29092`;
-- `localhost:9092` подходит только для клиентов на хосте.
+- `ddl_init`:
+ - `verify_only` (bool, default: `false`) — только проверка схемы, без применения DDL.
+- `kafka_load`:
+ - `limit` (int, default: `0`) — количество строк на поток (`0` = весь файл).
+ - `reset_topics` (bool, default: `true`) — удалить и создать топики заново.
+- `etl_pipeline`:
+ - `full_refresh` (bool, default: `true`) — очищать DDS перед загрузкой.
+ - `wait_stg_timeout_sec` (int, default: `600`) — таймаут ожидания данных в STG.
+
+## Роль Make-таргетов
+
+- `make up` — основной способ поднять стек.
+- `make ddl`, `make transform` — технический fallback для низкоуровневой диагностики вне Airflow.
+- Загрузка в Kafka в runbook выполняется только через DAG `kafka_load`.
+
+## Важные примечания
+
+- Для связей контейнеров используйте `kafka:29092` (не `localhost:9092`).
+- После `docker compose down -v` нужно заново выполнить:
+ 1. `ddl_init`
+ 2. `kafka_load`
+ 3. `etl_pipeline`
+
+## Связанные документы
+
+- План ingest: `plans/kafka_ingest_plan.md`
+- План DAG'ов: `plans/airflow_dags_plan.md`
+- Архитектура: `docs/ARCHITECTURE.md`