Слой ODS теперь собирается в Airflow
This commit is contained in:
@@ -84,6 +84,7 @@
|
|||||||
|
|
||||||
- Держать изменения минимальными и по теме задания (инфра, схема, ingest, витрины).
|
- Держать изменения минимальными и по теме задания (инфра, схема, ingest, витрины).
|
||||||
- Не коммитить секреты. Если требуется пароль/ключи — использовать `.env` и примеры `.env.example`.
|
- Не коммитить секреты. Если требуется пароль/ключи — использовать `.env` и примеры `.env.example`.
|
||||||
|
- Оформлять коммиты по правилам из [COMMIT_RULES.md](./docs/COMMIT_RULES.md).
|
||||||
- README/планы обновлять вместе с изменениями инфраструктуры/DDL.
|
- README/планы обновлять вместе с изменениями инфраструктуры/DDL.
|
||||||
- Для спорных или меняющихся API (особенно Airflow/operators/providers) проверять актуальную документацию через `context7` и фиксировать решение в коде/документации.
|
- Для спорных или меняющихся API (особенно Airflow/operators/providers) проверять актуальную документацию через `context7` и фиксировать решение в коде/документации.
|
||||||
- **Комментарии в коде — на русском языке**:
|
- **Комментарии в коде — на русском языке**:
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
- [README.md](./README.md) — пользовательская документация (быстрый старт, архитектура)
|
- [README.md](./README.md) — пользовательская документация (быстрый старт, архитектура)
|
||||||
- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — подробное описание слоёв и технических решений
|
- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — подробное описание слоёв и технических решений
|
||||||
- [data/DE-task.md](./data/DE-task.md) — исходное задание
|
- [data/DE-task.md](./data/DE-task.md) — исходное задание
|
||||||
|
- [COMMIT_RULES.md](./docs/COMMIT_RULES.md) — правила оформления коммитов
|
||||||
|
|
||||||
## Примечания по текущему состоянию (если что-то “не встаёт”)
|
## Примечания по текущему состоянию (если что-то “не встаёт”)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
Фокус проекта: быстро показать работающий end-to-end сценарий и понятным языком объяснить, как устроены слои и почему пайплайн не падает на "грязных" данных.
|
Фокус проекта: быстро показать работающий end-to-end сценарий и понятным языком объяснить, как устроены слои и почему пайплайн не падает на "грязных" данных.
|
||||||
|
|
||||||
Коротко про поток:
|
Коротко про поток:
|
||||||
`data/*.jsonl` -> Kafka (1 строка = 1 сообщение) -> ClickHouse `stg` (сырые JSON) -> `ods` (типизация + DQ) -> Airflow batch -> `dds` (сущности) -> `dm` (витрины VIEW) -> Superset.
|
`data/*.jsonl` -> Kafka (1 строка = 1 сообщение) -> ClickHouse `stg` (сырые JSON) -> Airflow batch `stg -> ods -> dds -> dm` -> Superset.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ make data # по умолчанию первые 50 строк
|
|||||||
# или: FULL=1 make data # полный датасет (1000 строк)
|
# или: FULL=1 make data # полный датасет (1000 строк)
|
||||||
```
|
```
|
||||||
|
|
||||||
Запуск batch-трансформации (ODS -> DDS -> DM) в Airflow (если DAG выключен, сначала unpause):
|
Запуск batch-трансформации (STG -> ODS -> DDS -> DM) в Airflow (если DAG выключен, сначала unpause):
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline \
|
docker compose exec -T airflow-webserver airflow dags trigger etl_pipeline \
|
||||||
--conf '{"full_refresh": true}'
|
--conf '{"full_refresh": true}'
|
||||||
@@ -98,8 +98,8 @@ flowchart TB
|
|||||||
DAG[DAG: ddl_init / etl_pipeline]
|
DAG[DAG: ddl_init / etl_pipeline]
|
||||||
end
|
end
|
||||||
|
|
||||||
Sources -->|make data| Kafka -->|MV| STG -->|MV| ODS -->|Batch SQL| DDS -->|VIEW| DM
|
Sources -->|make data| Kafka -->|MV| STG -->|Batch SQL| ODS -->|Batch SQL| DDS -->|VIEW| DM
|
||||||
DAG -.->|оркестрация| ODS & DDS & DM
|
DAG -.->|оркестрация| STG & ODS & DDS & DM
|
||||||
```
|
```
|
||||||
|
|
||||||
Особенность задания про "грязные данные": парсинг не валит pipeline, ошибки фиксируются в `ods.*_errors` и в поле `parse_errors`.
|
Особенность задания про "грязные данные": парсинг не валит pipeline, ошибки фиксируются в `ods.*_errors` и в поле `parse_errors`.
|
||||||
@@ -120,6 +120,7 @@ flowchart TB
|
|||||||
│ │ ├── ods/20_ods.sql
|
│ │ ├── ods/20_ods.sql
|
||||||
│ │ ├── dds/30_dds.sql
|
│ │ ├── dds/30_dds.sql
|
||||||
│ │ └── dm/40_dm.sql
|
│ │ └── dm/40_dm.sql
|
||||||
|
│ ├── ods/ # Batch SQL: STG -> ODS
|
||||||
│ ├── dds/ # Batch SQL: ODS -> DDS
|
│ ├── dds/ # Batch SQL: ODS -> DDS
|
||||||
│ └── dm/ # Batch SQL: DDS -> DM
|
│ └── dm/ # Batch SQL: DDS -> DM
|
||||||
├── scripts/ # Автоматизация (apply ddl, load data, run batch)
|
├── scripts/ # Автоматизация (apply ddl, load data, run batch)
|
||||||
@@ -142,7 +143,7 @@ flowchart TB
|
|||||||
| `make ddl` | Применить DDL в ClickHouse (вне Airflow) |
|
| `make ddl` | Применить DDL в ClickHouse (вне Airflow) |
|
||||||
| `make data` | Загрузить данные в Kafka (50 строк) |
|
| `make data` | Загрузить данные в Kafka (50 строк) |
|
||||||
| `FULL=1 make data` | Загрузить полный датасет |
|
| `FULL=1 make data` | Загрузить полный датасет |
|
||||||
| `make transform` | Запустить batch-процесс (вне Airflow) |
|
| `make transform` | Запустить batch-процесс `STG -> ODS -> DDS -> DM` (вне Airflow) |
|
||||||
|
|
||||||
Примечания про сохранность данных:
|
Примечания про сохранность данных:
|
||||||
- Данные ClickHouse сохраняются в Docker volume `clickhouse-data`.
|
- Данные ClickHouse сохраняются в Docker volume `clickhouse-data`.
|
||||||
@@ -216,7 +217,7 @@ flowchart LR
|
|||||||
|
|
||||||
Реализовано (Этап 1):
|
Реализовано (Этап 1):
|
||||||
- DAG `ddl_init`: последовательное применение DDL + проверка схемы.
|
- DAG `ddl_init`: последовательное применение DDL + проверка схемы.
|
||||||
- DAG `etl_pipeline`: precheck, ожидание данных в ODS, пересчёт DDS/DM, базовые проверки.
|
- DAG `etl_pipeline`: precheck, ожидание данных в STG, batch-пересчёт ODS/DDS/DM, базовые проверки.
|
||||||
- Устойчивость к "грязным" данным: ошибки парсинга сохраняются в ODS, а не валят ingest.
|
- Устойчивость к "грязным" данным: ошибки парсинга сохраняются в ODS, а не валят ingest.
|
||||||
|
|
||||||
В планах (не требуется для MVP задания):
|
В планах (не требуется для MVP задания):
|
||||||
|
|||||||
+92
-17
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
DAG ETL-процесса ODS -> DDS -> DM для учебного проекта.
|
DAG ETL-процесса STG -> ODS -> DDS -> DM для учебного проекта.
|
||||||
|
|
||||||
Принципы реализации:
|
Принципы реализации:
|
||||||
- SQL выполняется явными task на ClickHouseOperator;
|
- SQL выполняется явными task на ClickHouseOperator;
|
||||||
@@ -80,10 +80,61 @@ SELECT
|
|||||||
|
|
||||||
SQL_CHECK_ODS_QUALITY = """
|
SQL_CHECK_ODS_QUALITY = """
|
||||||
SELECT
|
SELECT
|
||||||
count() AS total_rows,
|
table_name,
|
||||||
countIf(length(parse_errors) > 0) AS rows_with_errors,
|
total_rows,
|
||||||
round(if(count() = 0, 0, countIf(length(parse_errors) > 0) / count() * 100), 2) AS error_pct
|
rows_with_errors,
|
||||||
|
round(if(total_rows = 0, 0, rows_with_errors / total_rows * 100), 2) AS error_pct
|
||||||
|
FROM
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
'browser_event' AS table_name,
|
||||||
|
toFloat64(count()) AS total_rows,
|
||||||
|
toFloat64(countIf(length(parse_errors) > 0)) AS rows_with_errors
|
||||||
FROM ods.browser_event
|
FROM ods.browser_event
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'location_event',
|
||||||
|
toFloat64(count()),
|
||||||
|
toFloat64(countIf(length(parse_errors) > 0))
|
||||||
|
FROM ods.location_event
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'device_by_click',
|
||||||
|
toFloat64(count()),
|
||||||
|
toFloat64(countIf(length(parse_errors) > 0))
|
||||||
|
FROM ods.device_by_click
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'geo_by_click',
|
||||||
|
toFloat64(count()),
|
||||||
|
toFloat64(countIf(length(parse_errors) > 0))
|
||||||
|
FROM ods.geo_by_click
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'browser_event_errors',
|
||||||
|
toFloat64(count()),
|
||||||
|
toFloat64(count())
|
||||||
|
FROM ods.browser_event_errors
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'location_event_errors',
|
||||||
|
toFloat64(count()),
|
||||||
|
toFloat64(count())
|
||||||
|
FROM ods.location_event_errors
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'device_by_click_errors',
|
||||||
|
toFloat64(count()),
|
||||||
|
toFloat64(count())
|
||||||
|
FROM ods.device_by_click_errors
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'geo_by_click_errors',
|
||||||
|
toFloat64(count()),
|
||||||
|
toFloat64(count())
|
||||||
|
FROM ods.geo_by_click_errors
|
||||||
|
)
|
||||||
|
ORDER BY table_name
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SQL_TRUNCATE_DDS_CLICK = "TRUNCATE TABLE dds.click"
|
SQL_TRUNCATE_DDS_CLICK = "TRUNCATE TABLE dds.click"
|
||||||
@@ -115,21 +166,38 @@ def assert_schema_ready(**context) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def wait_for_ods_data(**context) -> None:
|
def wait_for_stg_data(**context) -> None:
|
||||||
"""
|
"""
|
||||||
Ожидает появления строк в ods.browser_event до заданного таймаута.
|
Ожидает появления строк в STG до заданного таймаута.
|
||||||
Таймаут берётся из dag_run.conf.wait_ods_timeout_sec или из params.
|
Таймаут берётся из dag_run.conf.wait_stg_timeout_sec (или legacy wait_ods_timeout_sec)
|
||||||
|
либо из params.
|
||||||
"""
|
"""
|
||||||
dag_run = context.get("dag_run")
|
dag_run = context.get("dag_run")
|
||||||
conf = dag_run.conf if dag_run else {}
|
conf = dag_run.conf if dag_run else {}
|
||||||
timeout_sec = int(conf.get("wait_ods_timeout_sec", context["params"]["wait_ods_timeout_sec"]))
|
timeout_sec = int(
|
||||||
|
conf.get(
|
||||||
|
"wait_stg_timeout_sec",
|
||||||
|
conf.get(
|
||||||
|
"wait_ods_timeout_sec",
|
||||||
|
context["params"]["wait_stg_timeout_sec"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
poll_interval_sec = 10
|
poll_interval_sec = 10
|
||||||
|
|
||||||
hook = ClickHouseHook(clickhouse_conn_id="clickhouse_default", database="default")
|
hook = ClickHouseHook(clickhouse_conn_id="clickhouse_default", database="default")
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
rows = hook.execute("SELECT count() FROM ods.browser_event")
|
rows = hook.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
(SELECT count() FROM stg.browser_raw)
|
||||||
|
+ (SELECT count() FROM stg.location_raw)
|
||||||
|
+ (SELECT count() FROM stg.device_raw)
|
||||||
|
+ (SELECT count() FROM stg.geo_raw) AS stg_rows_total
|
||||||
|
"""
|
||||||
|
)
|
||||||
count_rows = int(rows[0][0]) if rows else 0
|
count_rows = int(rows[0][0]) if rows else 0
|
||||||
if count_rows > 0:
|
if count_rows > 0:
|
||||||
return
|
return
|
||||||
@@ -137,8 +205,8 @@ def wait_for_ods_data(**context) -> None:
|
|||||||
elapsed = int(time.monotonic() - started)
|
elapsed = int(time.monotonic() - started)
|
||||||
if elapsed >= timeout_sec:
|
if elapsed >= timeout_sec:
|
||||||
raise AirflowException(
|
raise AirflowException(
|
||||||
f"Таймаут ожидания ODS истёк ({timeout_sec} сек). "
|
f"Таймаут ожидания STG истёк ({timeout_sec} сек). "
|
||||||
"Таблица ods.browser_event всё ещё пуста."
|
"Таблицы stg.*_raw всё ещё пусты."
|
||||||
)
|
)
|
||||||
|
|
||||||
time.sleep(poll_interval_sec)
|
time.sleep(poll_interval_sec)
|
||||||
@@ -167,7 +235,7 @@ def assert_dm_summary_not_empty(**context) -> None:
|
|||||||
|
|
||||||
with DAG(
|
with DAG(
|
||||||
dag_id="etl_pipeline",
|
dag_id="etl_pipeline",
|
||||||
description="ETL ODS -> DDS -> DM для demo-проекта",
|
description="ETL STG -> ODS -> DDS -> DM для demo-проекта",
|
||||||
default_args=default_args,
|
default_args=default_args,
|
||||||
schedule=None,
|
schedule=None,
|
||||||
start_date=datetime(2024, 1, 1),
|
start_date=datetime(2024, 1, 1),
|
||||||
@@ -177,7 +245,7 @@ with DAG(
|
|||||||
tags=["etl", "clickhouse", "demo"],
|
tags=["etl", "clickhouse", "demo"],
|
||||||
params={
|
params={
|
||||||
"full_refresh": Param(True, type="boolean"),
|
"full_refresh": Param(True, type="boolean"),
|
||||||
"wait_ods_timeout_sec": Param(600, type="integer", minimum=30),
|
"wait_stg_timeout_sec": Param(600, type="integer", minimum=30),
|
||||||
},
|
},
|
||||||
) as dag:
|
) as dag:
|
||||||
with TaskGroup(group_id="precheck") as precheck:
|
with TaskGroup(group_id="precheck") as precheck:
|
||||||
@@ -203,9 +271,16 @@ with DAG(
|
|||||||
check_clickhouse >> check_schema_ready_sql >> check_schema_ready
|
check_clickhouse >> check_schema_ready_sql >> check_schema_ready
|
||||||
|
|
||||||
with TaskGroup(group_id="transform") as transform:
|
with TaskGroup(group_id="transform") as transform:
|
||||||
wait_for_ods_data_task = PythonOperator(
|
wait_for_stg_data_task = PythonOperator(
|
||||||
task_id="wait_for_ods_data",
|
task_id="wait_for_stg_data",
|
||||||
python_callable=wait_for_ods_data,
|
python_callable=wait_for_stg_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
load_ods = ClickHouseOperator(
|
||||||
|
task_id="load_ods",
|
||||||
|
sql=load_sql_statements("ods/20_stg_to_ods.sql"),
|
||||||
|
clickhouse_conn_id="clickhouse_default",
|
||||||
|
database="default",
|
||||||
)
|
)
|
||||||
|
|
||||||
check_ods_quality = ClickHouseOperator(
|
check_ods_quality = ClickHouseOperator(
|
||||||
@@ -274,7 +349,7 @@ with DAG(
|
|||||||
python_callable=assert_dm_summary_not_empty,
|
python_callable=assert_dm_summary_not_empty,
|
||||||
)
|
)
|
||||||
|
|
||||||
wait_for_ods_data_task >> check_ods_quality >> choose_refresh_mode
|
wait_for_stg_data_task >> load_ods >> check_ods_quality >> choose_refresh_mode
|
||||||
choose_refresh_mode >> truncate_dds_click >> truncate_dds_event >> truncate_complete
|
choose_refresh_mode >> truncate_dds_click >> truncate_dds_event >> truncate_complete
|
||||||
choose_refresh_mode >> skip_truncate >> truncate_complete
|
choose_refresh_mode >> skip_truncate >> truncate_complete
|
||||||
truncate_complete >> load_dds >> check_dds_integrity >> load_dm_summary >> validate_dm_summary_sql >> validate_dm_summary
|
truncate_complete >> load_dds >> check_dds_integrity >> load_dm_summary >> validate_dm_summary_sql >> validate_dm_summary
|
||||||
|
|||||||
+38
-29
@@ -44,8 +44,10 @@ flowchart LR
|
|||||||
S2[location_raw]
|
S2[location_raw]
|
||||||
S3[device_raw]
|
S3[device_raw]
|
||||||
S4[geo_raw]
|
S4[geo_raw]
|
||||||
MV1[mv_*_to_ods]
|
end
|
||||||
MV2[mv_*_to_errors]
|
|
||||||
|
subgraph AF["⚙️ Airflow"]
|
||||||
|
B1[load_ods<br/>sql/ods/20_stg_to_ods.sql]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph ODS["🔧 ODS"]
|
subgraph ODS["🔧 ODS"]
|
||||||
@@ -68,12 +70,20 @@ flowchart LR
|
|||||||
DM4[v_top_pages]
|
DM4[v_top_pages]
|
||||||
end
|
end
|
||||||
|
|
||||||
BE --> K1 --> S1 --> MV1 --> O1 --> DE1 --> DM1
|
BE --> K1 --> S1
|
||||||
LE --> K2 --> S2 --> MV1 --> O2 --> DE1
|
LE --> K2 --> S2
|
||||||
DE --> K3 --> S3 --> MV1 --> O3 --> DC1 --> DM1
|
DE --> K3 --> S3
|
||||||
GE --> K4 --> S4 --> MV1 --> O4 --> DC1
|
GE --> K4 --> S4
|
||||||
|
|
||||||
S1 & S2 & S3 & S4 --> MV2 -.-> OE
|
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
|
DE1 --> DM2 & DM3 & DM4
|
||||||
DC1 --> DM2 & DM3 & DM4
|
DC1 --> DM2 & DM3 & DM4
|
||||||
```
|
```
|
||||||
@@ -106,7 +116,7 @@ flowchart TB
|
|||||||
DM_T["VIEW для BI<br/>(Superset/Grafana)"]
|
DM_T["VIEW для BI<br/>(Superset/Grafana)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
RAW -->|kafka-console-producer| KAFKA -->|MV| STG_T -->|MV| ODS_T
|
RAW -->|kafka-console-producer| KAFKA -->|MV| STG_T -->|Batch SQL (Airflow)| ODS_T
|
||||||
ODS_T -->|argMax + JOIN| DDS_T -->|VIEW| DM_T
|
ODS_T -->|argMax + JOIN| DDS_T -->|VIEW| DM_T
|
||||||
ODS_T -.->|ошибки| DQ
|
ODS_T -.->|ошибки| DQ
|
||||||
```
|
```
|
||||||
@@ -159,14 +169,14 @@ CREATE TABLE stg.browser_raw (
|
|||||||
| `geo_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Гео-данные |
|
| `geo_by_click` | click_id | ReplacingMergeTree(src_ingest_ts) | Гео-данные |
|
||||||
| `*_errors` | — | MergeTree | Строки с битыми ключами |
|
| `*_errors` | — | MergeTree | Строки с битыми ключами |
|
||||||
|
|
||||||
**Materialized Views для обработки ошибок:**
|
**Batch шаг наполнения ODS:**
|
||||||
|
|
||||||
| MV | Назначение |
|
| Шаг | Назначение |
|
||||||
|----|-----------|
|
|-----|-----------|
|
||||||
| `mv_browser_raw_to_ods_errors` | Переносит строки с ошибками в `browser_event_errors` |
|
| `load_ods` (`sql/ods/20_stg_to_ods.sql`) | Полная пересборка ODS из STG в рамках DAG `etl_pipeline` |
|
||||||
| `mv_location_raw_to_ods_errors` | Переносит строки с ошибками в `location_event_errors` |
|
| `TRUNCATE ods.*` | Очистка перед пересборкой для детерминированного результата |
|
||||||
| `mv_device_raw_to_ods_errors` | Переносит строки с ошибками в `device_by_click_errors` |
|
| `INSERT ... SELECT` | Типизация валидных строк в основные ODS таблицы |
|
||||||
| `mv_geo_raw_to_ods_errors` | Переносит строки с ошибками в `geo_by_click_errors` |
|
| `INSERT ... SELECT` в `*_errors` | Сохранение строк с критичными ошибками парсинга |
|
||||||
|
|
||||||
**Логика разделения:**
|
**Логика разделения:**
|
||||||
- **Основная таблица**: строки с валидными ключами (`WHERE key IS NOT NULL`)
|
- **Основная таблица**: строки с валидными ключами (`WHERE key IS NOT NULL`)
|
||||||
@@ -203,7 +213,7 @@ GROUP BY error;
|
|||||||
**Почему так:**
|
**Почему так:**
|
||||||
- **Изоляция источников**: изменения в одном не ломают другие
|
- **Изоляция источников**: изменения в одном не ломают другие
|
||||||
- **Версионирование**: `ReplacingMergeTree` хранит последнюю версию по `src_ingest_ts`
|
- **Версионирование**: `ReplacingMergeTree` хранит последнюю версию по `src_ingest_ts`
|
||||||
- **Nullable ключи**: `allow_nullable_key = 1` позволяет хранить "битые" строки
|
- **Управляемость**: шаг `load_ods` виден в Airflow, есть task-level мониторинг и ретраи
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -356,6 +366,7 @@ FROM ...
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant User as Пользователь
|
participant User as Пользователь
|
||||||
participant Make as Makefile
|
participant Make as Makefile
|
||||||
|
participant Airflow as Airflow
|
||||||
participant K as Kafka
|
participant K as Kafka
|
||||||
participant CH as ClickHouse
|
participant CH as ClickHouse
|
||||||
participant STG as stg.*_raw
|
participant STG as stg.*_raw
|
||||||
@@ -371,7 +382,7 @@ sequenceDiagram
|
|||||||
User->>Make: make ddl
|
User->>Make: make ddl
|
||||||
Make->>CH: sql/ddl/00_databases.sql
|
Make->>CH: sql/ddl/00_databases.sql
|
||||||
Make->>CH: sql/ddl/stg/10_stg.sql (Kafka Engine)
|
Make->>CH: sql/ddl/stg/10_stg.sql (Kafka Engine)
|
||||||
Make->>CH: sql/ddl/ods/20_ods.sql (MV)
|
Make->>CH: sql/ddl/ods/20_ods.sql (таблицы ODS + drop legacy MV)
|
||||||
Make->>CH: sql/ddl/dds/30_dds.sql
|
Make->>CH: sql/ddl/dds/30_dds.sql
|
||||||
Make->>CH: sql/ddl/dm/40_dm.sql
|
Make->>CH: sql/ddl/dm/40_dm.sql
|
||||||
CH-->>User: ✅ Структура БД создана
|
CH-->>User: ✅ Структура БД создана
|
||||||
@@ -384,17 +395,17 @@ sequenceDiagram
|
|||||||
end
|
end
|
||||||
K->>CH: Потребление сообщений
|
K->>CH: Потребление сообщений
|
||||||
CH->>STG: INSERT через MV
|
CH->>STG: INSERT через MV
|
||||||
STG->>ODS: INSERT через MV (типизация)
|
|
||||||
K-->>User: ✅ Данные в Kafka
|
K-->>User: ✅ Данные в Kafka
|
||||||
CH-->>User: ✅ Данные в STG/ODS
|
CH-->>User: ✅ Данные в STG
|
||||||
|
|
||||||
User->>Make: make transform
|
User->>Airflow: Trigger etl_pipeline
|
||||||
Make->>CH: sql/dds/30_ods_to_dds.sql
|
Airflow->>CH: sql/ods/20_stg_to_ods.sql
|
||||||
|
Airflow->>CH: sql/dds/30_ods_to_dds.sql
|
||||||
CH->>ODS: argMax() — снапшот
|
CH->>ODS: argMax() — снапшот
|
||||||
CH->>DDS: JOIN + INSERT
|
CH->>DDS: JOIN + INSERT
|
||||||
Make->>CH: sql/dm/40_dds_to_dm.sql
|
Airflow->>CH: sql/dm/40_dds_to_dm.sql
|
||||||
CH->>DM: DQ summary
|
CH->>DM: DQ summary
|
||||||
CH-->>User: ✅ DDS/DM обновлены
|
CH-->>User: ✅ ODS/DDS/DM обновлены
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -506,7 +517,7 @@ flowchart LR
|
|||||||
|
|
||||||
**Решение:**
|
**Решение:**
|
||||||
1. Включаем `allow_nullable_key = 1` в `ReplacingMergeTree`
|
1. Включаем `allow_nullable_key = 1` в `ReplacingMergeTree`
|
||||||
2. Фильтруем NULL в MV (`WHERE key IS NOT NULL` → основная таблица)
|
2. Фильтруем NULL в batch SQL (`WHERE key IS NOT NULL` → основная таблица)
|
||||||
3. Отдельные `*_errors` таблицы для NULL-ключей
|
3. Отдельные `*_errors` таблицы для NULL-ключей
|
||||||
|
|
||||||
### Почему `ReplacingMergeTree`?
|
### Почему `ReplacingMergeTree`?
|
||||||
@@ -528,18 +539,16 @@ flowchart LR
|
|||||||
|
|
||||||
**Решение:**
|
**Решение:**
|
||||||
1. **Основная таблица**: только валидные строки (`WHERE key IS NOT NULL`)
|
1. **Основная таблица**: только валидные строки (`WHERE key IS NOT NULL`)
|
||||||
2. **Таблица ошибок**: строки с невалидными ключами через отдельные MV
|
2. **Таблица ошибок**: строки с невалидными ключами через отдельные `INSERT ... SELECT`
|
||||||
3. **DQ-метрики**: массив `parse_errors` для аудита
|
3. **DQ-метрики**: массив `parse_errors` для аудита
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Основная таблица
|
-- Основная таблица
|
||||||
CREATE MV mv_browser_raw_to_ods_browser_event
|
INSERT INTO ods.browser_event
|
||||||
TO ods.browser_event
|
|
||||||
SELECT ... FROM stg.browser_raw WHERE event_id IS NOT NULL;
|
SELECT ... FROM stg.browser_raw WHERE event_id IS NOT NULL;
|
||||||
|
|
||||||
-- Таблица ошибок
|
-- Таблица ошибок
|
||||||
CREATE MV mv_browser_raw_to_ods_errors
|
INSERT INTO ods.browser_event_errors
|
||||||
TO ods.browser_event_errors
|
|
||||||
SELECT ... FROM stg.browser_raw WHERE event_id IS NULL;
|
SELECT ... FROM stg.browser_raw WHERE event_id IS NULL;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Правила оформления коммитов
|
||||||
|
|
||||||
|
Документ задаёт единый стиль коммитов для всех участников проекта.
|
||||||
|
|
||||||
|
## Язык
|
||||||
|
|
||||||
|
- Язык коммитов: русский.
|
||||||
|
- Технические термины (`Airflow`, `ClickHouse`, `Kafka`, `MV`, `DDL`) допускаются на английском.
|
||||||
|
|
||||||
|
## Формат заголовка
|
||||||
|
|
||||||
|
- Формат: `<type>(<scope>): <краткое действие>`
|
||||||
|
- Максимальная длина заголовка: 72 символа.
|
||||||
|
- Заголовок пишется в повелительном стиле, без точки в конце.
|
||||||
|
|
||||||
|
### Разрешённые `type`
|
||||||
|
|
||||||
|
- `feat` — новая функциональность
|
||||||
|
- `fix` — исправление ошибки
|
||||||
|
- `refactor` — изменение структуры без смены поведения
|
||||||
|
- `docs` — документация
|
||||||
|
- `test` — тесты/проверки
|
||||||
|
- `chore` — сервисные изменения (конфиги, скрипты, хуки)
|
||||||
|
- `ci` — CI/CD
|
||||||
|
- `perf` — оптимизация производительности
|
||||||
|
- `revert` — откат коммита
|
||||||
|
|
||||||
|
### Рекомендуемые `scope` для этого репозитория
|
||||||
|
|
||||||
|
- `airflow`
|
||||||
|
- `stg`
|
||||||
|
- `ods`
|
||||||
|
- `dds`
|
||||||
|
- `dm`
|
||||||
|
- `kafka`
|
||||||
|
- `superset`
|
||||||
|
- `monitoring`
|
||||||
|
- `scripts`
|
||||||
|
- `docs`
|
||||||
|
- `infra`
|
||||||
|
|
||||||
|
## Структура тела коммита
|
||||||
|
|
||||||
|
Если изменение не тривиальное, тело коммита обязательно. Для удобства чтения используйте буллеты.
|
||||||
|
|
||||||
|
Рекомендуемый шаблон:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<type>(<scope>): <краткое действие>
|
||||||
|
|
||||||
|
- Зачем:
|
||||||
|
- причина изменения
|
||||||
|
- Что сделано:
|
||||||
|
- ключевое изменение 1
|
||||||
|
- ключевое изменение 2
|
||||||
|
- Проверка:
|
||||||
|
- как проверено (команда/тест/смоук-чек)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Размер и границы коммита
|
||||||
|
|
||||||
|
- Один коммит = одна логическая задача.
|
||||||
|
- Не смешивать в одном коммите функциональные изменения и крупный рефакторинг без необходимости.
|
||||||
|
- Документацию обновлять в том же коммите, где меняется поведение пайплайна или инфраструктуры.
|
||||||
|
|
||||||
|
## Ломающие изменения
|
||||||
|
|
||||||
|
- Для ломающих изменений используйте `!` в заголовке:
|
||||||
|
- `feat(ods)!: изменить контракт таблицы browser_event`
|
||||||
|
- Добавляйте footer:
|
||||||
|
- `BREAKING CHANGE: ...`
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
- `feat(ods): перенести STG->ODS в batch шаг Airflow`
|
||||||
|
- `fix(airflow): ждать данные в STG перед load_ods`
|
||||||
|
- `docs(architecture): обновить схему потока после миграции ODS`
|
||||||
|
- `chore(scripts): синхронизировать make transform с новым ETL`
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
| `check_clickhouse` | Проверка доступности CH (`SELECT 1`) | `PythonOperator` + `clickhouse-connect` |
|
| `check_clickhouse` | Проверка доступности CH (`SELECT 1`) | `PythonOperator` + `clickhouse-connect` |
|
||||||
| `ddl_00_databases` | Создание БД | `sql/ddl/00_databases.sql` |
|
| `ddl_00_databases` | Создание БД | `sql/ddl/00_databases.sql` |
|
||||||
| `ddl_10_stg` | STG + Kafka Engine + MV | `sql/ddl/stg/10_stg.sql` |
|
| `ddl_10_stg` | STG + Kafka Engine + MV | `sql/ddl/stg/10_stg.sql` |
|
||||||
| `ddl_20_ods` | ODS + MV STG→ODS + *_errors | `sql/ddl/ods/20_ods.sql` |
|
| `ddl_20_ods` | ODS таблицы + drop legacy MV STG→ODS | `sql/ddl/ods/20_ods.sql` |
|
||||||
| `ddl_30_dds` | Таблицы DDS | `sql/ddl/dds/30_dds.sql` |
|
| `ddl_30_dds` | Таблицы DDS | `sql/ddl/dds/30_dds.sql` |
|
||||||
| `ddl_40_dm` | VIEW витрины DM | `sql/ddl/dm/40_dm.sql` |
|
| `ddl_40_dm` | VIEW витрины DM | `sql/ddl/dm/40_dm.sql` |
|
||||||
| `verify_schema` | Проверка ключевых таблиц/VIEW | SQL-check |
|
| `verify_schema` | Проверка ключевых таблиц/VIEW | SQL-check |
|
||||||
@@ -98,7 +98,7 @@ precheck >> prepare_topics >> [load_browser_events, load_location_events, load_d
|
|||||||
## Дизайн DAG `etl_pipeline`
|
## Дизайн DAG `etl_pipeline`
|
||||||
### Params (через Trigger DAG with config)
|
### Params (через Trigger DAG with config)
|
||||||
- `full_refresh`: bool, default `true`.
|
- `full_refresh`: bool, default `true`.
|
||||||
- `wait_ods_timeout_sec`: int, default `600`.
|
- `wait_stg_timeout_sec`: int, default `600`.
|
||||||
|
|
||||||
### TaskGroup `precheck`
|
### TaskGroup `precheck`
|
||||||
| Task ID | Что делает | Реализация |
|
| Task ID | Что делает | Реализация |
|
||||||
@@ -109,7 +109,8 @@ precheck >> prepare_topics >> [load_browser_events, load_location_events, load_d
|
|||||||
### TaskGroup `transform`
|
### TaskGroup `transform`
|
||||||
| Task ID | Что делает | Источник SQL |
|
| Task ID | Что делает | Источник SQL |
|
||||||
|---------|------------|--------------|
|
|---------|------------|--------------|
|
||||||
| `wait_for_ods_data` | Ожидание строк в `ods.browser_event` | SQL-check |
|
| `wait_for_stg_data` | Ожидание строк в `stg.*_raw` | SQL-check |
|
||||||
|
| `load_ods` | Batch STG → ODS (основные таблицы + *_errors) | `sql/ods/20_stg_to_ods.sql` |
|
||||||
| `check_ods_quality` | Базовые DQ-метрики ODS (ошибки/total) | SQL-check |
|
| `check_ods_quality` | Базовые DQ-метрики ODS (ошибки/total) | SQL-check |
|
||||||
| `truncate_dds_click` | Очистка `dds.click` при `full_refresh=true` | inline SQL |
|
| `truncate_dds_click` | Очистка `dds.click` при `full_refresh=true` | inline SQL |
|
||||||
| `truncate_dds_event` | Очистка `dds.event` при `full_refresh=true` | inline SQL |
|
| `truncate_dds_event` | Очистка `dds.event` при `full_refresh=true` | inline SQL |
|
||||||
@@ -120,7 +121,7 @@ precheck >> prepare_topics >> [load_browser_events, load_location_events, load_d
|
|||||||
|
|
||||||
Зависимости:
|
Зависимости:
|
||||||
```text
|
```text
|
||||||
wait_for_ods_data >> check_ods_quality >> [truncate_dds_click, truncate_dds_event] >> load_dds >> check_dds_integrity >> load_dm_summary >> validate_dm_summary
|
wait_for_stg_data >> load_ods >> check_ods_quality >> [truncate_dds_click, truncate_dds_event] >> load_dds >> check_dds_integrity >> load_dm_summary >> validate_dm_summary
|
||||||
```
|
```
|
||||||
|
|
||||||
### Итоговая цепочка `etl_pipeline`
|
### Итоговая цепочка `etl_pipeline`
|
||||||
@@ -165,7 +166,7 @@ dags/
|
|||||||
├── __init__.py
|
├── __init__.py
|
||||||
├── ddl_init_dag.py # отдельный DAG для DDL (обязателен)
|
├── ddl_init_dag.py # отдельный DAG для DDL (обязателен)
|
||||||
├── kafka_load_dag.py # отдельный DAG для ingest в Kafka (обязателен)
|
├── kafka_load_dag.py # отдельный DAG для ingest в Kafka (обязателен)
|
||||||
├── etl_pipeline_dag.py # основной DAG ODS -> DDS -> DM (обязателен)
|
├── etl_pipeline_dag.py # основной DAG STG -> ODS -> DDS -> DM (обязателен)
|
||||||
├── dq_monitor_dag.py # опциональный DAG мониторинга
|
├── dq_monitor_dag.py # опциональный DAG мониторинга
|
||||||
└── utils/
|
└── utils/
|
||||||
├── __init__.py
|
├── __init__.py
|
||||||
@@ -189,7 +190,7 @@ dags/
|
|||||||
## Критерии готовности
|
## Критерии готовности
|
||||||
- Этап 1:
|
- Этап 1:
|
||||||
- В Airflow UI видны DAG `ddl_init` и `etl_pipeline`.
|
- В Airflow UI видны DAG `ddl_init` и `etl_pipeline`.
|
||||||
- `etl_pipeline` падает с понятной ошибкой, если схема не применена или ODS пуста.
|
- `etl_pipeline` падает с понятной ошибкой, если схема не применена или STG пуста.
|
||||||
- После прогона `make data -> etl_pipeline`:
|
- После прогона `make data -> etl_pipeline`:
|
||||||
- в `ods.browser_event` есть строки;
|
- в `ods.browser_event` есть строки;
|
||||||
- в `dds.click` и `dds.event` есть строки;
|
- в `dds.click` и `dds.event` есть строки;
|
||||||
|
|||||||
+17
-13
@@ -31,16 +31,16 @@
|
|||||||
|
|
||||||
Эта схема укладывается в задание так:
|
Эта схема укладывается в задание так:
|
||||||
|
|
||||||
- **Kafka → STG → ODS** можно сделать полностью внутри ClickHouse через `ENGINE = Kafka` + MV (стриминг, 1 json = 1 row).
|
- **Kafka → STG** реализуется внутри ClickHouse через `ENGINE = Kafka` + MV (стриминг, 1 json = 1 row).
|
||||||
- “**регулярный процесс**” — батч‑трансформации `ODS → DDS (→ DM)` в виде SQL (`INSERT INTO … SELECT …`) по расписанию (в будущем Airflow; пока — ручной запуск).
|
- “**регулярный процесс**” — батч‑трансформации `STG → ODS → DDS (→ DM)` в виде SQL (`INSERT INTO … SELECT …`) по расписанию (Airflow).
|
||||||
- Airflow (когда появится) можно использовать как **тонкий оркестратор**: применить DDL и запускать batch‑SQL по расписанию.
|
- Airflow используется как основной оркестратор batch‑шагов и DQ‑проверок.
|
||||||
|
|
||||||
### Выбранное решение (MVP)
|
### Выбранное решение (MVP)
|
||||||
|
|
||||||
Чтобы сделать “хорошо, но без оверинжиниринга”, фиксируем такое MVP:
|
Чтобы сделать “хорошо, но без оверинжиниринга”, фиксируем такое MVP:
|
||||||
|
|
||||||
- **STG → ODS** — инкрементально через MV (парсинг/типизация рядом с ingest).
|
- **STG → ODS** — batch через SQL (`sql/ods/20_stg_to_ods.sql`) внутри `etl_pipeline`.
|
||||||
- **DDS** — батч‑сборка из ODS через SQL (`INSERT INTO … SELECT …`) как “регулярный процесс”.
|
- **DDS** — batch‑сборка из ODS через SQL (`INSERT INTO … SELECT …`) как “регулярный процесс”.
|
||||||
- Причина: `MV + JOIN` в `ODS → DDS` плохо переносит произвольный порядок прихода данных и может давать некорректные результаты (eventual consistency ODS, версии в разных партициях и т.п.).
|
- Причина: `MV + JOIN` в `ODS → DDS` плохо переносит произвольный порядок прихода данных и может давать некорректные результаты (eventual consistency ODS, версии в разных партициях и т.п.).
|
||||||
- **DM** — `VIEW` (витрины “на чтении”) поверх DDS, чтобы не плодить лишние таблицы и джобы под демо.
|
- **DM** — `VIEW` (витрины “на чтении”) поверх DDS, чтобы не плодить лишние таблицы и джобы под демо.
|
||||||
- На стороне BI считаем, что запросы всегда идут с фильтрами по времени (`event_date`/`event_ts`) и не сканируют всю историю.
|
- На стороне BI считаем, что запросы всегда идут с фильтрами по времени (`event_date`/`event_ts`) и не сканируют всю историю.
|
||||||
@@ -75,10 +75,10 @@ flowchart LR
|
|||||||
v_dq[dm.v_dq_errors_daily]
|
v_dq[dm.v_dq_errors_daily]
|
||||||
end
|
end
|
||||||
|
|
||||||
stg_browser -->|MV parse| ods_browser
|
stg_browser -->|Batch SQL| ods_browser
|
||||||
stg_location -->|MV parse| ods_location
|
stg_location -->|Batch SQL| ods_location
|
||||||
stg_device -->|MV parse| ods_device
|
stg_device -->|Batch SQL| ods_device
|
||||||
stg_geo -->|MV parse| ods_geo
|
stg_geo -->|Batch SQL| ods_geo
|
||||||
|
|
||||||
ods_browser -->|Batch SQL| dds_event
|
ods_browser -->|Batch SQL| dds_event
|
||||||
ods_location -->|Batch SQL| dds_event
|
ods_location -->|Batch SQL| dds_event
|
||||||
@@ -125,7 +125,7 @@ flowchart LR
|
|||||||
|
|
||||||
- `sql/ddl/00_databases.sql` — базы `stg/ods/dds/dm`.
|
- `sql/ddl/00_databases.sql` — базы `stg/ods/dds/dm`.
|
||||||
- `sql/ddl/stg/10_stg.sql` — STG raw (`stg.*_raw`) + Kafka source tables (`ENGINE = Kafka`) + MV `Kafka → STG`.
|
- `sql/ddl/stg/10_stg.sql` — STG raw (`stg.*_raw`) + Kafka source tables (`ENGINE = Kafka`) + MV `Kafka → STG`.
|
||||||
- `sql/ddl/ods/20_ods.sql` — ODS таблицы типизации + DQ (`parse_errors`) + MV `STG → ODS` + таблицы `ods_*_errors` для строк с битыми ключами.
|
- `sql/ddl/ods/20_ods.sql` — ODS таблицы типизации + DQ (`parse_errors`) + удаление legacy MV `STG → ODS`.
|
||||||
- `sql/ddl/dds/30_dds.sql` — DDS таблицы (`dds.event`, `dds.click`) **без MV** (только `CREATE TABLE`).
|
- `sql/ddl/dds/30_dds.sql` — DDS таблицы (`dds.event`, `dds.click`) **без MV** (только `CREATE TABLE`).
|
||||||
- `sql/ddl/dm/40_dm.sql` — витрины `VIEW` для Superset (`dm.v_*`).
|
- `sql/ddl/dm/40_dm.sql` — витрины `VIEW` для Superset (`dm.v_*`).
|
||||||
|
|
||||||
@@ -136,13 +136,17 @@ BI-ограничения (ресурсы/пользователь) **не вы
|
|||||||
- `sql/dds/30_ods_to_dds.sql` — регулярная батч‑сборка DDS из ODS:
|
- `sql/dds/30_ods_to_dds.sql` — регулярная батч‑сборка DDS из ODS:
|
||||||
- получить “последнюю версию” строк по ключам (`event_id`/`click_id`) через `argMax(..., src_ingest_ts)` (или эквивалент);
|
- получить “последнюю версию” строк по ключам (`event_id`/`click_id`) через `argMax(..., src_ingest_ts)` (или эквивалент);
|
||||||
- выполнить join snapshot’ов и загрузить в `dds.event`/`dds.click` (для демо возможно “full rebuild”; позже — инкрементально).
|
- выполнить join snapshot’ов и загрузить в `dds.event`/`dds.click` (для демо возможно “full rebuild”; позже — инкрементально).
|
||||||
|
- `sql/ods/20_stg_to_ods.sql` — регулярная батч‑сборка ODS из STG:
|
||||||
|
- очистить ODS (`TRUNCATE`) перед пересборкой;
|
||||||
|
- типизировать валидные строки в `ods.*`;
|
||||||
|
- сложить критичные ошибки парсинга в `ods.*_errors`.
|
||||||
|
|
||||||
### Исполнение DDL (make сейчас / Airflow потом)
|
### Исполнение DDL (make сейчас / Airflow потом)
|
||||||
|
|
||||||
Требования к файлам `sql/*/*.sql`:
|
Требования к файлам `sql/*/*.sql`:
|
||||||
|
|
||||||
- идемпотентность (`IF NOT EXISTS`), чтобы повторные прогоны были безопасны;
|
- идемпотентность (`IF NOT EXISTS`), чтобы повторные прогоны были безопасны;
|
||||||
- строгий порядок исполнения: `00 → 10 → 20 → 30 → 40` (из‑за зависимостей MV);
|
- строгий порядок исполнения: `00 → 10 → 20 → 30 → 40` (из‑за зависимостей объектов);
|
||||||
- единые имена топиков Kafka: `browser_events`, `location_events`, `device_events`, `geo_events` (их создаёт `make data`).
|
- единые имена топиков Kafka: `browser_events`, `location_events`, `device_events`, `geo_events` (их создаёт `make data`).
|
||||||
|
|
||||||
### Дедупликация и обработка “битых” ключей (ODS)
|
### Дедупликация и обработка “битых” ключей (ODS)
|
||||||
@@ -167,8 +171,8 @@ BI-ограничения (ресурсы/пользователь) **не вы
|
|||||||
|
|
||||||
Batch‑трансформации (целевое, для реализации следующим шагом):
|
Batch‑трансформации (целевое, для реализации следующим шагом):
|
||||||
|
|
||||||
- `make transform` (или аналогичная команда) запускает `sql/dds/30_ods_to_dds.sql` через `clickhouse-client`;
|
- `make transform` (или аналогичная команда) запускает `sql/ods/20_stg_to_ods.sql`, `sql/dds/30_ods_to_dds.sql` и `sql/dm/40_dds_to_dm.sql` через `clickhouse-client`;
|
||||||
- в будущем Airflow будет делать то же самое по расписанию (один job‑SQL = один task).
|
- Airflow может выполнять те же шаги как отдельные task (с ретраями и мониторингом).
|
||||||
|
|
||||||
### Параметры окружения (docker compose)
|
### Параметры окружения (docker compose)
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -32,8 +32,9 @@ make ddl
|
|||||||
## Make таргеты
|
## Make таргеты
|
||||||
|
|
||||||
- `make up` — `docker compose up -d` (поднимает весь стек из `docker-compose.yml`).
|
- `make up` — `docker compose up -d` (поднимает весь стек из `docker-compose.yml`).
|
||||||
- `make ddl` — применяет SQL из `plans/clickhouse_ddl.md` в контейнер ClickHouse (извлекает все блоки ```sql``` и исполняет их через `clickhouse-client`).
|
- `make ddl` — применяет исполняемые SQL-файлы из `sql/ddl/*` в контейнер ClickHouse через `clickhouse-client`.
|
||||||
- `make data` — пересоздаёт топики (по умолчанию) и публикует события из `data/*.jsonl` в Kafka (1 строка = 1 Kafka message value).
|
- `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`.
|
План реализации механики заливки (дизайн/решения): `plans/kafka_ingest_plan.md`.
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ RESET_TOPICS=0 make data
|
|||||||
|
|
||||||
## Применение DDL в ClickHouse (`make ddl`)
|
## Применение DDL в ClickHouse (`make ddl`)
|
||||||
|
|
||||||
Скрипт исполняет SQL из `plans/clickhouse_ddl.md`. Для `ENGINE = Kafka` важно, чтобы `kafka_broker_list` был доступен из контейнера ClickHouse.
|
Скрипт исполняет SQL-файлы из `sql/ddl/*` по фиксированному порядку. Для `ENGINE = Kafka` важно, чтобы `kafka_broker_list` был доступен из контейнера ClickHouse.
|
||||||
|
|
||||||
В текущем compose:
|
В текущем compose:
|
||||||
|
|
||||||
|
|||||||
+60
-15
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Скрипт batch-трансформации данных: ODS → DDS → DM
|
# Скрипт batch-трансформации данных: STG → ODS → DDS → DM
|
||||||
#
|
#
|
||||||
# Назначение:
|
# Назначение:
|
||||||
# Запускает SQL-скрипты из sql/dds и sql/dm для преобразования данных между слоями:
|
# Запускает SQL-скрипты из sql/ods, sql/dds и sql/dm для преобразования данных между слоями:
|
||||||
# 1. ODS → DDS : Сборка сущностей из типизированных данных
|
# 1. STG → ODS : Типизация и перенос ошибок в *_errors
|
||||||
# 2. DDS → DM : Обновление сводки по качеству данных (dq_summary)
|
# 2. ODS → DDS : Сборка сущностей из типизированных данных
|
||||||
|
# 3. DDS → DM : Обновление сводки по качеству данных (dq_summary)
|
||||||
#
|
#
|
||||||
# Как запускать:
|
# Как запускать:
|
||||||
# make transform
|
# make transform
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
#
|
#
|
||||||
# Требования:
|
# Требования:
|
||||||
# - ClickHouse запущен (make up)
|
# - ClickHouse запущен (make up)
|
||||||
# - ODS содержит данные (make data выполнен)
|
# - STG содержит данные (make data выполнен)
|
||||||
#
|
#
|
||||||
# Стратегия:
|
# Стратегия:
|
||||||
# Сейчас: полная перезагрузка (TRUNCATE + INSERT) — для демо
|
# Сейчас: полная перезагрузка (TRUNCATE + INSERT) — для демо
|
||||||
@@ -25,6 +26,7 @@ set -euo pipefail
|
|||||||
# Директория со скриптом
|
# Директория со скриптом
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SQL_ROOT_DIR="${SCRIPT_DIR}/../sql"
|
SQL_ROOT_DIR="${SCRIPT_DIR}/../sql"
|
||||||
|
ODS_TRANSFORM_SQL="${SQL_ROOT_DIR}/ods/20_stg_to_ods.sql"
|
||||||
DDS_TRANSFORM_SQL="${SQL_ROOT_DIR}/dds/30_ods_to_dds.sql"
|
DDS_TRANSFORM_SQL="${SQL_ROOT_DIR}/dds/30_ods_to_dds.sql"
|
||||||
DM_TRANSFORM_SQL="${SQL_ROOT_DIR}/dm/40_dds_to_dm.sql"
|
DM_TRANSFORM_SQL="${SQL_ROOT_DIR}/dm/40_dds_to_dm.sql"
|
||||||
|
|
||||||
@@ -47,6 +49,11 @@ fi
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Проверка: SQL-файлы batch существуют?
|
# Проверка: SQL-файлы batch существуют?
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
if [[ ! -f "${ODS_TRANSFORM_SQL}" ]]; then
|
||||||
|
echo "Ошибка: Не найден SQL-файл: ${ODS_TRANSFORM_SQL}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "${DDS_TRANSFORM_SQL}" ]]; then
|
if [[ ! -f "${DDS_TRANSFORM_SQL}" ]]; then
|
||||||
echo "Ошибка: Не найден SQL-файл: ${DDS_TRANSFORM_SQL}"
|
echo "Ошибка: Не найден SQL-файл: ${DDS_TRANSFORM_SQL}"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -58,27 +65,65 @@ if [[ ! -f "${DM_TRANSFORM_SQL}" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Проверка: в ODS есть данные?
|
# Проверка: в STG есть данные?
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
ODS_COUNT=$(${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \
|
STG_COUNT=$(${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \
|
||||||
--user="${CLICKHOUSE_USER}" \
|
--user="${CLICKHOUSE_USER}" \
|
||||||
--password="${CLICKHOUSE_PASSWORD}" \
|
--password="${CLICKHOUSE_PASSWORD}" \
|
||||||
--database="${CLICKHOUSE_DB}" \
|
--database="${CLICKHOUSE_DB}" \
|
||||||
--query="SELECT count() FROM ods.browser_event" 2>/dev/null || echo "0")
|
--query="
|
||||||
|
SELECT
|
||||||
|
(SELECT count() FROM stg.browser_raw)
|
||||||
|
+ (SELECT count() FROM stg.location_raw)
|
||||||
|
+ (SELECT count() FROM stg.device_raw)
|
||||||
|
+ (SELECT count() FROM stg.geo_raw) AS stg_rows_total
|
||||||
|
" 2>/dev/null || echo "0")
|
||||||
|
|
||||||
if [[ "${ODS_COUNT}" == "0" ]]; then
|
if [[ "${STG_COUNT}" == "0" ]]; then
|
||||||
echo "Предупреждение: Таблица ODS.browser_event пуста."
|
echo "Предупреждение: Таблицы STG пусты."
|
||||||
echo "Сначала загрузите данные: make data"
|
echo "Сначала загрузите данные: make data"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Найдено ${ODS_COUNT} строк в ODS.browser_event"
|
echo "Найдено ${STG_COUNT} строк в STG (суммарно по 4 потокам)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Шаг 1: ODS → DDS (сборка сущностей)
|
# Шаг 1: STG → ODS (типизация и DQ)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
echo "Шаг 1: Обновление DDS слоя (ODS → DDS)..."
|
echo "Шаг 1: Обновление ODS слоя (STG → ODS)..."
|
||||||
|
${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \
|
||||||
|
--user="${CLICKHOUSE_USER}" \
|
||||||
|
--password="${CLICKHOUSE_PASSWORD}" \
|
||||||
|
--database="${CLICKHOUSE_DB}" \
|
||||||
|
--multiquery < "${ODS_TRANSFORM_SQL}"
|
||||||
|
|
||||||
|
echo " ✓ ODS обновлён"
|
||||||
|
|
||||||
|
# Показываем статистику ODS
|
||||||
|
echo ""
|
||||||
|
echo "Статистика ODS:"
|
||||||
|
${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \
|
||||||
|
--user="${CLICKHOUSE_USER}" \
|
||||||
|
--password="${CLICKHOUSE_PASSWORD}" \
|
||||||
|
--database="${CLICKHOUSE_DB}" \
|
||||||
|
--query="
|
||||||
|
SELECT 'ods.browser_event' AS table, count() AS rows FROM ods.browser_event
|
||||||
|
UNION ALL SELECT 'ods.location_event', count() FROM ods.location_event
|
||||||
|
UNION ALL SELECT 'ods.device_by_click', count() FROM ods.device_by_click
|
||||||
|
UNION ALL SELECT 'ods.geo_by_click', count() FROM ods.geo_by_click
|
||||||
|
UNION ALL SELECT 'ods.browser_event_errors', count() FROM ods.browser_event_errors
|
||||||
|
UNION ALL SELECT 'ods.location_event_errors', count() FROM ods.location_event_errors
|
||||||
|
UNION ALL SELECT 'ods.device_by_click_errors', count() FROM ods.device_by_click_errors
|
||||||
|
UNION ALL SELECT 'ods.geo_by_click_errors', count() FROM ods.geo_by_click_errors
|
||||||
|
FORMAT PrettyCompact
|
||||||
|
"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Шаг 2: ODS → DDS (сборка сущностей)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "Шаг 2: Обновление DDS слоя (ODS → DDS)..."
|
||||||
echo " - Очистка текущих данных (TRUNCATE)..."
|
echo " - Очистка текущих данных (TRUNCATE)..."
|
||||||
|
|
||||||
# Очищаем таблицы перед загрузкой (полная перезагрузка для демо)
|
# Очищаем таблицы перед загрузкой (полная перезагрузка для демо)
|
||||||
@@ -112,10 +157,10 @@ ${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \
|
|||||||
--query="SELECT 'dds.click' AS table, count() AS rows FROM dds.click UNION ALL SELECT 'dds.event', count() FROM dds.event FORMAT PrettyCompact"
|
--query="SELECT 'dds.click' AS table, count() AS rows FROM dds.click UNION ALL SELECT 'dds.event', count() FROM dds.event FORMAT PrettyCompact"
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Шаг 2: DDS → DM (сводка по качеству)
|
# Шаг 3: DDS → DM (сводка по качеству)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo "Шаг 2: Обновление DM слоя (DQ summary)..."
|
echo "Шаг 3: Обновление DM слоя (DQ summary)..."
|
||||||
${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \
|
${COMPOSE_BIN} exec -T "${CLICKHOUSE_SERVICE}" clickhouse-client \
|
||||||
--user="${CLICKHOUSE_USER}" \
|
--user="${CLICKHOUSE_USER}" \
|
||||||
--password="${CLICKHOUSE_PASSWORD}" \
|
--password="${CLICKHOUSE_PASSWORD}" \
|
||||||
|
|||||||
+24
-233
@@ -2,15 +2,28 @@
|
|||||||
-- Слой ODS (Operational Data Store) — типизированные данные + дедупликация + DQ
|
-- Слой ODS (Operational Data Store) — типизированные данные + дедупликация + DQ
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- Назначение:
|
-- Назначение:
|
||||||
-- - Типизация данных из STG (String → UUID, DateTime, etc.)
|
-- - Хранение типизированных данных для дальнейшей сборки DDS
|
||||||
-- - Дедупликация через ReplacingMergeTree (последняя версия по src_ingest_ts)
|
-- - Дедупликация через ReplacingMergeTree (последняя версия по src_ingest_ts)
|
||||||
-- - Контроль качества: массив parse_errors для "грязных" данных
|
-- - Контроль качества: массив parse_errors для "грязных" данных
|
||||||
-- - Разделение: валидные строки → основная таблица, ошибки → *_errors
|
-- - Разделение: валидные строки → основная таблица, ошибки → *_errors
|
||||||
--
|
--
|
||||||
-- Поток данных:
|
-- Важно:
|
||||||
-- STG (*_raw) → MV → ODS (основная таблица + error_tables)
|
-- - Наполнение ODS выполняется batch-процессом из sql/ods/20_stg_to_ods.sql
|
||||||
|
-- - Materialized View STG → ODS в target-state не используются
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Удаляем legacy MV STG → ODS (если ранее были созданы)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
DROP TABLE IF EXISTS stg.mv_browser_raw_to_ods;
|
||||||
|
DROP TABLE IF EXISTS stg.mv_browser_raw_to_ods_errors;
|
||||||
|
DROP TABLE IF EXISTS stg.mv_location_raw_to_ods;
|
||||||
|
DROP TABLE IF EXISTS stg.mv_location_raw_to_ods_errors;
|
||||||
|
DROP TABLE IF EXISTS stg.mv_device_raw_to_ods;
|
||||||
|
DROP TABLE IF EXISTS stg.mv_device_raw_to_ods_errors;
|
||||||
|
DROP TABLE IF EXISTS stg.mv_geo_raw_to_ods;
|
||||||
|
DROP TABLE IF EXISTS stg.mv_geo_raw_to_ods_errors;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- BROWSER EVENTS
|
-- BROWSER EVENTS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -18,9 +31,6 @@
|
|||||||
-- ----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
-- Основная таблица: валидные строки (event_id IS NOT NULL)
|
-- Основная таблица: валидные строки (event_id IS NOT NULL)
|
||||||
-- ----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
-- ReplacingMergeTree: при мердже оставляет строку с максимальным src_ingest_ts
|
|
||||||
-- allow_nullable_key = 1: разрешаем NULL в ключе (ClickHouse по умолчанию запрещает)
|
|
||||||
-- ----------------------------------------------------------------------------
|
|
||||||
CREATE TABLE IF NOT EXISTS ods.browser_event
|
CREATE TABLE IF NOT EXISTS ods.browser_event
|
||||||
(
|
(
|
||||||
event_id Nullable(UUID), -- UUID события (ключ)
|
event_id Nullable(UUID), -- UUID события (ключ)
|
||||||
@@ -35,101 +45,28 @@ CREATE TABLE IF NOT EXISTS ods.browser_event
|
|||||||
src_raw String, -- Исходный JSON для аудита
|
src_raw String, -- Исходный JSON для аудита
|
||||||
parse_errors Array(LowCardinality(String)) -- Ошибки парсинга (если есть)
|
parse_errors Array(LowCardinality(String)) -- Ошибки парсинга (если есть)
|
||||||
)
|
)
|
||||||
ENGINE = ReplacingMergeTree(src_ingest_ts) -- Движок дедупликации по версии
|
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
||||||
PARTITION BY toYYYYMM(event_date) -- Партиции по месяцу для быстрой очистки
|
PARTITION BY toYYYYMM(event_date)
|
||||||
ORDER BY (event_id) -- Ключ сортировки (и дедупликации)
|
ORDER BY (event_id)
|
||||||
SETTINGS allow_nullable_key = 1; -- Разрешаем NULL в ключе (для "битых" данных)
|
SETTINGS allow_nullable_key = 1;
|
||||||
|
|
||||||
-- ----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
-- Таблица ошибок: строки с невалидными ключами (event_id IS NULL)
|
-- Таблица ошибок: строки с невалидными ключами/критичными ошибками browser
|
||||||
-- ----------------------------------------------------------------------------
|
|
||||||
-- Сохраняем полную информацию для анализа проблем с данными
|
|
||||||
-- MergeTree без Replacing: сохраняем все ошибки (не дедуплицируем)
|
|
||||||
-- ----------------------------------------------------------------------------
|
-- ----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS ods.browser_event_errors
|
CREATE TABLE IF NOT EXISTS ods.browser_event_errors
|
||||||
(
|
(
|
||||||
ingest_ts DateTime64(3), -- Время вставки в ClickHouse
|
ingest_ts DateTime64(3), -- Время вставки в ClickHouse
|
||||||
kafka_topic LowCardinality(String), -- Топик Kafka (для отслеживания источника)
|
kafka_topic LowCardinality(String), -- Топик Kafka (для отслеживания источника)
|
||||||
kafka_partition Int32, -- Партиция Kafka
|
kafka_partition Int32, -- Партиция Kafka
|
||||||
kafka_offset Int64, -- Смещение Kafka (уникальный идентификатор сообщения)
|
kafka_offset Int64, -- Смещение Kafka (идентификатор сообщения)
|
||||||
kafka_ts DateTime64(3), -- Время из Kafka
|
kafka_ts DateTime64(3), -- Время из Kafka
|
||||||
raw String, -- Исходный JSON
|
raw String, -- Исходный JSON
|
||||||
error_reason LowCardinality(String) -- Описание ошибки (что именно не распарсилось)
|
error_reason LowCardinality(String) -- Описание ошибки
|
||||||
)
|
)
|
||||||
ENGINE = MergeTree
|
ENGINE = MergeTree
|
||||||
PARTITION BY toYYYYMM(ingest_ts)
|
PARTITION BY toYYYYMM(ingest_ts)
|
||||||
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
||||||
|
|
||||||
-- ----------------------------------------------------------------------------
|
|
||||||
-- MV: STG → ODS (основная таблица)
|
|
||||||
-- ----------------------------------------------------------------------------
|
|
||||||
-- Фильтруем только валидные строки: WHERE event_id IS NOT NULL
|
|
||||||
-- Парсим JSON, типизируем поля, собираем ошибки в массив parse_errors
|
|
||||||
-- ----------------------------------------------------------------------------
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_browser_raw_to_ods
|
|
||||||
TO ods.browser_event
|
|
||||||
AS
|
|
||||||
WITH
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
|
|
||||||
parseDateTime64BestEffortOrNull(JSONExtractString(raw, 'event_timestamp'), 6) AS event_ts,
|
|
||||||
JSONExtractString(raw, 'event_type') AS event_type,
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
|
||||||
JSONExtractString(raw, 'browser_name') AS browser_name,
|
|
||||||
JSONExtractString(raw, 'browser_user_agent') AS browser_user_agent,
|
|
||||||
JSONExtractString(raw, 'browser_language') AS browser_language
|
|
||||||
SELECT
|
|
||||||
event_id,
|
|
||||||
event_ts,
|
|
||||||
event_type,
|
|
||||||
click_id,
|
|
||||||
browser_name,
|
|
||||||
browser_user_agent,
|
|
||||||
browser_language,
|
|
||||||
ingest_ts AS src_ingest_ts,
|
|
||||||
raw AS src_raw,
|
|
||||||
-- Собираем ошибки парсинга в массив (пустые строки фильтруем)
|
|
||||||
arrayFilter(x -> x != '', [
|
|
||||||
if(event_id IS NULL, 'bad_event_id', ''),
|
|
||||||
if(event_ts IS NULL, 'bad_event_timestamp', ''),
|
|
||||||
if(click_id IS NULL, 'bad_click_id', '')
|
|
||||||
]) AS parse_errors
|
|
||||||
FROM stg.browser_raw
|
|
||||||
WHERE event_id IS NOT NULL; -- Только валидные строки (NULL → в error_tables)
|
|
||||||
|
|
||||||
-- ----------------------------------------------------------------------------
|
|
||||||
-- MV: STG → ODS (таблица ошибок)
|
|
||||||
-- ----------------------------------------------------------------------------
|
|
||||||
-- Перенаправляем строки с ошибками парсинга в отдельную таблицу
|
|
||||||
-- Это позволяет не терять данные и анализировать проблемы
|
|
||||||
-- ----------------------------------------------------------------------------
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_browser_raw_to_ods_errors
|
|
||||||
TO ods.browser_event_errors
|
|
||||||
AS
|
|
||||||
WITH
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
|
|
||||||
parseDateTime64BestEffortOrNull(JSONExtractString(raw, 'event_timestamp'), 6) AS event_ts,
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
|
||||||
arrayFilter(x -> x != '', [
|
|
||||||
if(event_id IS NULL, 'bad_event_id', ''),
|
|
||||||
if(event_ts IS NULL, 'bad_event_timestamp', ''),
|
|
||||||
if(click_id IS NULL, 'bad_click_id', '')
|
|
||||||
]) AS parse_errors
|
|
||||||
SELECT
|
|
||||||
ingest_ts,
|
|
||||||
kafka_topic,
|
|
||||||
kafka_partition,
|
|
||||||
kafka_offset,
|
|
||||||
kafka_ts,
|
|
||||||
raw,
|
|
||||||
arrayStringConcat(parse_errors, ',') AS error_reason
|
|
||||||
FROM stg.browser_raw
|
|
||||||
WHERE length(parse_errors) > 0 -- Есть хотя бы одна ошибка
|
|
||||||
AND (
|
|
||||||
event_id IS NULL
|
|
||||||
OR event_ts IS NULL
|
|
||||||
OR click_id IS NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- LOCATION EVENTS
|
-- LOCATION EVENTS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -150,7 +87,7 @@ CREATE TABLE IF NOT EXISTS ods.location_event
|
|||||||
parse_errors Array(LowCardinality(String))
|
parse_errors Array(LowCardinality(String))
|
||||||
)
|
)
|
||||||
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
ENGINE = ReplacingMergeTree(src_ingest_ts)
|
||||||
PARTITION BY toYYYYMM(toDate(src_ingest_ts)) -- Партиция по времени загрузки (нет event_date)
|
PARTITION BY toYYYYMM(toDate(src_ingest_ts))
|
||||||
ORDER BY (event_id)
|
ORDER BY (event_id)
|
||||||
SETTINGS allow_nullable_key = 1;
|
SETTINGS allow_nullable_key = 1;
|
||||||
|
|
||||||
@@ -168,49 +105,6 @@ ENGINE = MergeTree
|
|||||||
PARTITION BY toYYYYMM(ingest_ts)
|
PARTITION BY toYYYYMM(ingest_ts)
|
||||||
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
||||||
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_location_raw_to_ods
|
|
||||||
TO ods.location_event
|
|
||||||
AS
|
|
||||||
WITH
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id
|
|
||||||
SELECT
|
|
||||||
event_id,
|
|
||||||
JSONExtractString(raw, 'page_url') AS page_url,
|
|
||||||
JSONExtractString(raw, 'page_url_path') AS page_url_path,
|
|
||||||
JSONExtractString(raw, 'referer_url') AS referer_url,
|
|
||||||
JSONExtractString(raw, 'referer_medium') AS referer_medium,
|
|
||||||
JSONExtractString(raw, 'utm_medium') AS utm_medium,
|
|
||||||
JSONExtractString(raw, 'utm_source') AS utm_source,
|
|
||||||
JSONExtractString(raw, 'utm_content') AS utm_content,
|
|
||||||
JSONExtractString(raw, 'utm_campaign') AS utm_campaign,
|
|
||||||
ingest_ts AS src_ingest_ts,
|
|
||||||
raw AS src_raw,
|
|
||||||
arrayFilter(x -> x != '', [
|
|
||||||
if(event_id IS NULL, 'bad_event_id', '')
|
|
||||||
]) AS parse_errors
|
|
||||||
FROM stg.location_raw
|
|
||||||
WHERE event_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_location_raw_to_ods_errors
|
|
||||||
TO ods.location_event_errors
|
|
||||||
AS
|
|
||||||
WITH
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
|
|
||||||
arrayFilter(x -> x != '', [
|
|
||||||
if(event_id IS NULL, 'bad_event_id', '')
|
|
||||||
]) AS parse_errors
|
|
||||||
SELECT
|
|
||||||
ingest_ts,
|
|
||||||
kafka_topic,
|
|
||||||
kafka_partition,
|
|
||||||
kafka_offset,
|
|
||||||
kafka_ts,
|
|
||||||
raw,
|
|
||||||
arrayStringConcat(parse_errors, ',') AS error_reason
|
|
||||||
FROM stg.location_raw
|
|
||||||
WHERE length(parse_errors) > 0
|
|
||||||
AND event_id IS NULL;
|
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- DEVICE EVENTS
|
-- DEVICE EVENTS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -248,56 +142,6 @@ ENGINE = MergeTree
|
|||||||
PARTITION BY toYYYYMM(ingest_ts)
|
PARTITION BY toYYYYMM(ingest_ts)
|
||||||
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
||||||
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_device_raw_to_ods
|
|
||||||
TO ods.device_by_click
|
|
||||||
AS
|
|
||||||
WITH
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
|
||||||
JSONExtract(raw, 'device_is_mobile', 'Nullable(UInt8)') AS device_is_mobile,
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'user_domain_id')) AS user_domain_id
|
|
||||||
SELECT
|
|
||||||
click_id,
|
|
||||||
JSONExtractString(raw, 'os') AS os,
|
|
||||||
JSONExtractString(raw, 'os_name') AS os_name,
|
|
||||||
JSONExtractString(raw, 'os_timezone') AS os_timezone,
|
|
||||||
JSONExtractString(raw, 'device_type') AS device_type,
|
|
||||||
device_is_mobile,
|
|
||||||
JSONExtractString(raw, 'user_custom_id') AS user_custom_id,
|
|
||||||
user_domain_id,
|
|
||||||
ingest_ts AS src_ingest_ts,
|
|
||||||
raw AS src_raw,
|
|
||||||
arrayFilter(x -> x != '', [
|
|
||||||
if(click_id IS NULL, 'bad_click_id', ''),
|
|
||||||
if(user_domain_id IS NULL, 'bad_user_domain_id', '')
|
|
||||||
]) AS parse_errors
|
|
||||||
FROM stg.device_raw
|
|
||||||
WHERE click_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_device_raw_to_ods_errors
|
|
||||||
TO ods.device_by_click_errors
|
|
||||||
AS
|
|
||||||
WITH
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'user_domain_id')) AS user_domain_id,
|
|
||||||
arrayFilter(x -> x != '', [
|
|
||||||
if(click_id IS NULL, 'bad_click_id', ''),
|
|
||||||
if(user_domain_id IS NULL, 'bad_user_domain_id', '')
|
|
||||||
]) AS parse_errors
|
|
||||||
SELECT
|
|
||||||
ingest_ts,
|
|
||||||
kafka_topic,
|
|
||||||
kafka_partition,
|
|
||||||
kafka_offset,
|
|
||||||
kafka_ts,
|
|
||||||
raw,
|
|
||||||
arrayStringConcat(parse_errors, ',') AS error_reason
|
|
||||||
FROM stg.device_raw
|
|
||||||
WHERE length(parse_errors) > 0
|
|
||||||
AND (
|
|
||||||
click_id IS NULL
|
|
||||||
OR user_domain_id IS NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- GEO EVENTS
|
-- GEO EVENTS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -333,56 +177,3 @@ CREATE TABLE IF NOT EXISTS ods.geo_by_click_errors
|
|||||||
ENGINE = MergeTree
|
ENGINE = MergeTree
|
||||||
PARTITION BY toYYYYMM(ingest_ts)
|
PARTITION BY toYYYYMM(ingest_ts)
|
||||||
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
ORDER BY (ingest_ts, kafka_topic, kafka_partition, kafka_offset);
|
||||||
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_geo_raw_to_ods
|
|
||||||
TO ods.geo_by_click
|
|
||||||
AS
|
|
||||||
WITH
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
|
||||||
toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude,
|
|
||||||
toFloat64OrNull(JSONExtractString(raw, 'geo_longitude')) AS geo_longitude
|
|
||||||
SELECT
|
|
||||||
click_id,
|
|
||||||
geo_latitude,
|
|
||||||
geo_longitude,
|
|
||||||
JSONExtractString(raw, 'geo_country') AS geo_country,
|
|
||||||
JSONExtractString(raw, 'geo_timezone') AS geo_timezone,
|
|
||||||
JSONExtractString(raw, 'geo_region_name') AS geo_region_name,
|
|
||||||
JSONExtractString(raw, 'ip_address') AS ip_address,
|
|
||||||
ingest_ts AS src_ingest_ts,
|
|
||||||
raw AS src_raw,
|
|
||||||
arrayFilter(x -> x != '', [
|
|
||||||
if(click_id IS NULL, 'bad_click_id', ''),
|
|
||||||
if(geo_latitude IS NULL, 'bad_geo_latitude', ''),
|
|
||||||
if(geo_longitude IS NULL, 'bad_geo_longitude', '')
|
|
||||||
]) AS parse_errors
|
|
||||||
FROM stg.geo_raw
|
|
||||||
WHERE click_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.mv_geo_raw_to_ods_errors
|
|
||||||
TO ods.geo_by_click_errors
|
|
||||||
AS
|
|
||||||
WITH
|
|
||||||
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
|
||||||
toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude,
|
|
||||||
toFloat64OrNull(JSONExtractString(raw, 'geo_longitude')) AS geo_longitude,
|
|
||||||
arrayFilter(x -> x != '', [
|
|
||||||
if(click_id IS NULL, 'bad_click_id', ''),
|
|
||||||
if(geo_latitude IS NULL, 'bad_geo_latitude', ''),
|
|
||||||
if(geo_longitude IS NULL, 'bad_geo_longitude', '')
|
|
||||||
]) AS parse_errors
|
|
||||||
SELECT
|
|
||||||
ingest_ts,
|
|
||||||
kafka_topic,
|
|
||||||
kafka_partition,
|
|
||||||
kafka_offset,
|
|
||||||
kafka_ts,
|
|
||||||
raw,
|
|
||||||
arrayStringConcat(parse_errors, ',') AS error_reason
|
|
||||||
FROM stg.geo_raw
|
|
||||||
WHERE length(parse_errors) > 0
|
|
||||||
AND (
|
|
||||||
click_id IS NULL
|
|
||||||
OR geo_latitude IS NULL
|
|
||||||
OR geo_longitude IS NULL
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -85,10 +85,33 @@ WHERE length(parse_errors) > 0
|
|||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT today(), 'ods', 'location_event', 'total_rows', count() FROM ods.location_event
|
SELECT today(), 'ods', 'location_event', 'total_rows', count() FROM ods.location_event
|
||||||
|
UNION ALL
|
||||||
|
SELECT today(), 'ods', 'location_event', 'rows_with_errors', count()
|
||||||
|
FROM ods.location_event
|
||||||
|
WHERE length(parse_errors) > 0
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT today(), 'ods', 'device_by_click', 'total_rows', count() FROM ods.device_by_click
|
SELECT today(), 'ods', 'device_by_click', 'total_rows', count() FROM ods.device_by_click
|
||||||
|
UNION ALL
|
||||||
|
SELECT today(), 'ods', 'device_by_click', 'rows_with_errors', count()
|
||||||
|
FROM ods.device_by_click
|
||||||
|
WHERE length(parse_errors) > 0
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT today(), 'ods', 'geo_by_click', 'total_rows', count() FROM ods.geo_by_click
|
SELECT today(), 'ods', 'geo_by_click', 'total_rows', count() FROM ods.geo_by_click
|
||||||
|
UNION ALL
|
||||||
|
SELECT today(), 'ods', 'geo_by_click', 'rows_with_errors', count()
|
||||||
|
FROM ods.geo_by_click
|
||||||
|
WHERE length(parse_errors) > 0
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
SELECT today(), 'ods', 'browser_event_errors', 'total_rows', count() FROM ods.browser_event_errors
|
||||||
|
UNION ALL
|
||||||
|
SELECT today(), 'ods', 'location_event_errors', 'total_rows', count() FROM ods.location_event_errors
|
||||||
|
UNION ALL
|
||||||
|
SELECT today(), 'ods', 'device_by_click_errors', 'total_rows', count() FROM ods.device_by_click_errors
|
||||||
|
UNION ALL
|
||||||
|
SELECT today(), 'ods', 'geo_by_click_errors', 'total_rows', count() FROM ods.geo_by_click_errors
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT today(), 'dds', 'event', 'total_rows', count() FROM dds.event
|
SELECT today(), 'dds', 'event', 'total_rows', count() FROM dds.event
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Batch-трансформация: STG → ODS
|
||||||
|
-- ============================================================================
|
||||||
|
-- Назначение:
|
||||||
|
-- - Перенос типизации STG → ODS из Materialized View в управляемый batch
|
||||||
|
-- - Полная пересборка ODS для прозрачного мониторинга в Airflow
|
||||||
|
-- - Сохранение DQ-логики: parse_errors + отдельные *_errors таблицы
|
||||||
|
--
|
||||||
|
-- Когда запускать:
|
||||||
|
-- - В DAG etl_pipeline перед ODS → DDS
|
||||||
|
-- - После загрузки очередного среза данных в Kafka/STG
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Подготовка: очищаем ODS перед полной пересборкой
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
TRUNCATE TABLE ods.browser_event;
|
||||||
|
TRUNCATE TABLE ods.browser_event_errors;
|
||||||
|
TRUNCATE TABLE ods.location_event;
|
||||||
|
TRUNCATE TABLE ods.location_event_errors;
|
||||||
|
TRUNCATE TABLE ods.device_by_click;
|
||||||
|
TRUNCATE TABLE ods.device_by_click_errors;
|
||||||
|
TRUNCATE TABLE ods.geo_by_click;
|
||||||
|
TRUNCATE TABLE ods.geo_by_click_errors;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- BROWSER EVENTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Основная таблица: валидные ключи event_id
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
INSERT INTO ods.browser_event
|
||||||
|
WITH
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
|
||||||
|
parseDateTime64BestEffortOrNull(JSONExtractString(raw, 'event_timestamp'), 6) AS event_ts,
|
||||||
|
JSONExtractString(raw, 'event_type') AS event_type,
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||||
|
JSONExtractString(raw, 'browser_name') AS browser_name,
|
||||||
|
JSONExtractString(raw, 'browser_user_agent') AS browser_user_agent,
|
||||||
|
JSONExtractString(raw, 'browser_language') AS browser_language
|
||||||
|
SELECT
|
||||||
|
event_id,
|
||||||
|
event_ts,
|
||||||
|
event_type,
|
||||||
|
click_id,
|
||||||
|
browser_name,
|
||||||
|
browser_user_agent,
|
||||||
|
browser_language,
|
||||||
|
ingest_ts AS src_ingest_ts,
|
||||||
|
raw AS src_raw,
|
||||||
|
arrayFilter(x -> x != '', [
|
||||||
|
if(event_id IS NULL, 'bad_event_id', ''),
|
||||||
|
if(event_ts IS NULL, 'bad_event_timestamp', ''),
|
||||||
|
if(click_id IS NULL, 'bad_click_id', '')
|
||||||
|
]) AS parse_errors
|
||||||
|
FROM stg.browser_raw
|
||||||
|
WHERE event_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Таблица ошибок: строки с ошибками парсинга browser
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
INSERT INTO ods.browser_event_errors
|
||||||
|
WITH
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
|
||||||
|
parseDateTime64BestEffortOrNull(JSONExtractString(raw, 'event_timestamp'), 6) AS event_ts,
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||||
|
arrayFilter(x -> x != '', [
|
||||||
|
if(event_id IS NULL, 'bad_event_id', ''),
|
||||||
|
if(event_ts IS NULL, 'bad_event_timestamp', ''),
|
||||||
|
if(click_id IS NULL, 'bad_click_id', '')
|
||||||
|
]) AS parse_errors
|
||||||
|
SELECT
|
||||||
|
ingest_ts,
|
||||||
|
kafka_topic,
|
||||||
|
kafka_partition,
|
||||||
|
kafka_offset,
|
||||||
|
kafka_ts,
|
||||||
|
raw,
|
||||||
|
arrayStringConcat(parse_errors, ',') AS error_reason
|
||||||
|
FROM stg.browser_raw
|
||||||
|
WHERE length(parse_errors) > 0
|
||||||
|
AND (
|
||||||
|
event_id IS NULL
|
||||||
|
OR event_ts IS NULL
|
||||||
|
OR click_id IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- LOCATION EVENTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Основная таблица: валидные ключи event_id
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
INSERT INTO ods.location_event
|
||||||
|
WITH
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id
|
||||||
|
SELECT
|
||||||
|
event_id,
|
||||||
|
JSONExtractString(raw, 'page_url') AS page_url,
|
||||||
|
JSONExtractString(raw, 'page_url_path') AS page_url_path,
|
||||||
|
JSONExtractString(raw, 'referer_url') AS referer_url,
|
||||||
|
JSONExtractString(raw, 'referer_medium') AS referer_medium,
|
||||||
|
JSONExtractString(raw, 'utm_medium') AS utm_medium,
|
||||||
|
JSONExtractString(raw, 'utm_source') AS utm_source,
|
||||||
|
JSONExtractString(raw, 'utm_content') AS utm_content,
|
||||||
|
JSONExtractString(raw, 'utm_campaign') AS utm_campaign,
|
||||||
|
ingest_ts AS src_ingest_ts,
|
||||||
|
raw AS src_raw,
|
||||||
|
arrayFilter(x -> x != '', [
|
||||||
|
if(event_id IS NULL, 'bad_event_id', '')
|
||||||
|
]) AS parse_errors
|
||||||
|
FROM stg.location_raw
|
||||||
|
WHERE event_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Таблица ошибок: строки с ошибками парсинга location
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
INSERT INTO ods.location_event_errors
|
||||||
|
WITH
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'event_id')) AS event_id,
|
||||||
|
arrayFilter(x -> x != '', [
|
||||||
|
if(event_id IS NULL, 'bad_event_id', '')
|
||||||
|
]) AS parse_errors
|
||||||
|
SELECT
|
||||||
|
ingest_ts,
|
||||||
|
kafka_topic,
|
||||||
|
kafka_partition,
|
||||||
|
kafka_offset,
|
||||||
|
kafka_ts,
|
||||||
|
raw,
|
||||||
|
arrayStringConcat(parse_errors, ',') AS error_reason
|
||||||
|
FROM stg.location_raw
|
||||||
|
WHERE length(parse_errors) > 0
|
||||||
|
AND event_id IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- DEVICE EVENTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Основная таблица: валидные ключи click_id
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
INSERT INTO ods.device_by_click
|
||||||
|
WITH
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||||
|
JSONExtract(raw, 'device_is_mobile', 'Nullable(UInt8)') AS device_is_mobile,
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'user_domain_id')) AS user_domain_id
|
||||||
|
SELECT
|
||||||
|
click_id,
|
||||||
|
JSONExtractString(raw, 'os') AS os,
|
||||||
|
JSONExtractString(raw, 'os_name') AS os_name,
|
||||||
|
JSONExtractString(raw, 'os_timezone') AS os_timezone,
|
||||||
|
JSONExtractString(raw, 'device_type') AS device_type,
|
||||||
|
device_is_mobile,
|
||||||
|
JSONExtractString(raw, 'user_custom_id') AS user_custom_id,
|
||||||
|
user_domain_id,
|
||||||
|
ingest_ts AS src_ingest_ts,
|
||||||
|
raw AS src_raw,
|
||||||
|
arrayFilter(x -> x != '', [
|
||||||
|
if(click_id IS NULL, 'bad_click_id', ''),
|
||||||
|
if(user_domain_id IS NULL, 'bad_user_domain_id', '')
|
||||||
|
]) AS parse_errors
|
||||||
|
FROM stg.device_raw
|
||||||
|
WHERE click_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Таблица ошибок: строки с ошибками парсинга device
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
INSERT INTO ods.device_by_click_errors
|
||||||
|
WITH
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'user_domain_id')) AS user_domain_id,
|
||||||
|
arrayFilter(x -> x != '', [
|
||||||
|
if(click_id IS NULL, 'bad_click_id', ''),
|
||||||
|
if(user_domain_id IS NULL, 'bad_user_domain_id', '')
|
||||||
|
]) AS parse_errors
|
||||||
|
SELECT
|
||||||
|
ingest_ts,
|
||||||
|
kafka_topic,
|
||||||
|
kafka_partition,
|
||||||
|
kafka_offset,
|
||||||
|
kafka_ts,
|
||||||
|
raw,
|
||||||
|
arrayStringConcat(parse_errors, ',') AS error_reason
|
||||||
|
FROM stg.device_raw
|
||||||
|
WHERE length(parse_errors) > 0
|
||||||
|
AND (
|
||||||
|
click_id IS NULL
|
||||||
|
OR user_domain_id IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- GEO EVENTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Основная таблица: валидные ключи click_id
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
INSERT INTO ods.geo_by_click
|
||||||
|
WITH
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||||
|
toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude,
|
||||||
|
toFloat64OrNull(JSONExtractString(raw, 'geo_longitude')) AS geo_longitude
|
||||||
|
SELECT
|
||||||
|
click_id,
|
||||||
|
geo_latitude,
|
||||||
|
geo_longitude,
|
||||||
|
JSONExtractString(raw, 'geo_country') AS geo_country,
|
||||||
|
JSONExtractString(raw, 'geo_timezone') AS geo_timezone,
|
||||||
|
JSONExtractString(raw, 'geo_region_name') AS geo_region_name,
|
||||||
|
JSONExtractString(raw, 'ip_address') AS ip_address,
|
||||||
|
ingest_ts AS src_ingest_ts,
|
||||||
|
raw AS src_raw,
|
||||||
|
arrayFilter(x -> x != '', [
|
||||||
|
if(click_id IS NULL, 'bad_click_id', ''),
|
||||||
|
if(geo_latitude IS NULL, 'bad_geo_latitude', ''),
|
||||||
|
if(geo_longitude IS NULL, 'bad_geo_longitude', '')
|
||||||
|
]) AS parse_errors
|
||||||
|
FROM stg.geo_raw
|
||||||
|
WHERE click_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Таблица ошибок: строки с ошибками парсинга geo
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
INSERT INTO ods.geo_by_click_errors
|
||||||
|
WITH
|
||||||
|
toUUIDOrNull(JSONExtractString(raw, 'click_id')) AS click_id,
|
||||||
|
toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS geo_latitude,
|
||||||
|
toFloat64OrNull(JSONExtractString(raw, 'geo_longitude')) AS geo_longitude,
|
||||||
|
arrayFilter(x -> x != '', [
|
||||||
|
if(click_id IS NULL, 'bad_click_id', ''),
|
||||||
|
if(geo_latitude IS NULL, 'bad_geo_latitude', ''),
|
||||||
|
if(geo_longitude IS NULL, 'bad_geo_longitude', '')
|
||||||
|
]) AS parse_errors
|
||||||
|
SELECT
|
||||||
|
ingest_ts,
|
||||||
|
kafka_topic,
|
||||||
|
kafka_partition,
|
||||||
|
kafka_offset,
|
||||||
|
kafka_ts,
|
||||||
|
raw,
|
||||||
|
arrayStringConcat(parse_errors, ',') AS error_reason
|
||||||
|
FROM stg.geo_raw
|
||||||
|
WHERE length(parse_errors) > 0
|
||||||
|
AND (
|
||||||
|
click_id IS NULL
|
||||||
|
OR geo_latitude IS NULL
|
||||||
|
OR geo_longitude IS NULL
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user