feat(airflow): подключён официальный провайдер ClickHouse

- Зачем:
  - нужен единый штатный путь исполнения SQL и программных запросов ClickHouse из Airflow.
- Что:
  - добавлен официальный провайдер и подключения типа `clickhouse`.
  - SQL-файлы переданы `SQLExecuteQueryOperator`, а программные запросы — `ClickHouseHook`.
  - разбор заказов собран в одном файле, DDL разделён по задачам, документация и смоук обновлены.
- Проверка:
  - выполнены `make lint` и `make config-test`.
  - собран образ, выполнены `pip check`, `make rebuild-storage`, `make smoke` (21/21), `make check-clickhouse` (10/10) и `make check-services` (7/7).
This commit is contained in:
ddadmin committed 2026-08-22 01:40:56 -04:00
1 parent 587e392220
commit 8d82679bc1
17 files changed
+393 -438

No files matched your search

+5 -3
View File
@@ -374,10 +374,12 @@ Airflow закреплён на 3.3.0. Состав обязательных п
[архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html),
[публичным интерфейсом](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html)
и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html).
Для пробников проверены публичный `Connection.get` из `airflow.sdk` и клиент
Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет
общий `SQLExecuteQueryOperator`, программные запросы идут через
`ClickHouseHook`. Провайдер работает поверх закреплённого
`clickhouse-connect`. Официальный провайдер Kafka сам использует
`confluent-kafka`; отдельное подключение и обёртки провайдера здесь не нужны,
поэтому прямой клиент оставляет образ и пример короче.
`confluent-kafka`; отдельное подключение и его обёртки здесь не нужны, поэтому
клиент Kafka добавлен в образ напрямую.
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма
`clickhousedb://` и драйвер Postgres сверены с
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
+2 -2
View File
@@ -79,8 +79,8 @@ x-airflow-common: &airflow-common
KAFKA_ORDERS_TOPIC: *orders-topic
volumes:
- ./dags:/opt/airflow/dags:ro
# Запросы дагов: даг называет файл, а текст читает Airflow при исполнении
# задачи. Каталог тот же, что применяет DDL, — вся SQL стенда живёт в sql/.
# Запросы дагов: даг называет файл, а обработчик DAG при разборе загружает
# его текст. Каталог тот же, что применяет DDL, — вся SQL стенда живёт в sql/.
- ./sql:/opt/airflow/sql:ro
# Инициализация мира собирает канонический образ из того же контекста, что
# ручной generator. Docker SDK архивирует эти файлы внутри задачи.
+20 -62
View File
@@ -18,9 +18,9 @@
from __future__ import annotations
import datetime
import logging
from airflow.sdk import Connection, dag, get_current_context, task
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.sdk import dag
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
@@ -43,67 +43,25 @@ SQL_ROOT = "/opt/airflow/sql"
)
def orders_ingest():
"""Забрать приехавший слепок заказов и разложить его по слоям."""
def clickhouse_client():
# Импорт при выполнении задачи, а не при разборе файла: обработчик DAG
# разбирает его снова и снова, и импорт наверху оплачивался бы каждым
# разбором.
import clickhouse_connect
connection = Connection.get("clickhouse_default")
return clickhouse_connect.get_client(
host=connection.host,
port=connection.port,
username=connection.login,
password=connection.password,
database=connection.schema or "default",
connect_timeout=5,
send_receive_timeout=30,
)
# Аргумент с расширением из templates_exts Airflow подменяет текстом файла:
# берёт его из template_searchpath и прогоняет через Jinja при исполнении
# задачи, а не при разборе дага. В задачу приезжает готовый запрос — вместе
# с тем, что файл подключил через include.
@task(templates_exts=(".sql",))
def pull_batch(sql: str) -> None:
load_id = get_current_context()["run_id"]
client = clickhouse_client()
try:
summary = client.command(sql, parameters={"load_id": load_id})
finally:
client.close()
# Размер порции — read_rows: written_rows у вставки в Distributed
# считает не приехавшее.
logging.info(
"порция принята: строк %s, _load_id %s",
summary.summary["read_rows"],
load_id,
)
@task(templates_exts=(".sql",))
def parse_batch(good_rows_sql: str, bad_rows_sql: str) -> None:
"""Разобрать срез сырья в версии заказов и в брак.
Обе вставки в одном task_id: транзакции между ними ClickHouse не даёт,
а повтор задачи безопасен — срез читается по тому же неизменному
_load_id (docs/architecture/orders/ingestion.md, «Поток данных»).
"""
load_id = get_current_context()["run_id"]
client = clickhouse_client()
try:
client.command(good_rows_sql, parameters={"load_id": load_id})
client.command(bad_rows_sql, parameters={"load_id": load_id})
finally:
client.close()
# Счётчиков строк нет: у запроса с WHERE read_rows считает прочитанное
# с диска, а не подошедшее (storage.md, «Что проверено»).
logging.info("срез разобран: _load_id %s", load_id)
pull_batch("stg/orders_raw_load.sql") >> parse_batch(
"ods/order_load.sql",
"ods/order_errors_load.sql",
pull_batch = SQLExecuteQueryOperator(
task_id="pull_batch",
conn_id="clickhouse_default",
sql="stg/orders_raw_load.sql",
parameters={"load_id": "{{ run_id }}"},
do_xcom_push=False,
)
parse_batch = SQLExecuteQueryOperator(
task_id="parse_batch",
conn_id="clickhouse_default",
sql="ods/order_parse.sql",
parameters={"load_id": "{{ run_id }}"},
split_statements=True,
do_xcom_push=False,
)
# ClickHouse не даёт транзакции между двумя вставками parse_batch. Один
# task_id сохраняет их порядок и одну точку повтора для неизменного load_id.
pull_batch >> parse_batch
orders_ingest()
+88 -116
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import datetime
import uuid
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.sdk import Connection, dag, get_current_context, task
CLUSTER = "clickstream_cluster"
@@ -54,27 +55,12 @@ def _remote_parameters() -> dict[str, str]:
}
def _clickhouse_client():
# clickhouse_connect импортируется внутри функции, а не наверху файла:
# обработчик DAG разбирает этот файл снова и снова, и импорт наверху
# оплачивался бы каждым разбором. Тяжёлые импорты Airflow советует
# держать внутри задач.
import clickhouse_connect
connection = Connection.get("clickhouse_default")
return clickhouse_connect.get_client(
host=connection.host,
port=connection.port,
username=connection.login,
password=connection.password,
database=connection.schema or "default",
connect_timeout=5,
send_receive_timeout=30,
)
def _clickhouse_hook() -> ClickHouseHook:
return ClickHouseHook(clickhouse_conn_id="clickhouse_default")
def _table_engines(client, source: str) -> list[tuple[str, str]]:
result = client.query(
def _table_engines(hook: ClickHouseHook, source: str) -> list[tuple[str, str]]:
return hook.get_records(
f"""
SELECT name, engine
FROM {source}
@@ -84,23 +70,23 @@ def _table_engines(client, source: str) -> list[tuple[str, str]]:
""",
parameters=_remote_parameters(),
)
return result.result_rows
def _drop_tables(client) -> None:
client.command(
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER} SYNC"
)
client.command(
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC"
def _drop_tables(hook: ClickHouseHook) -> None:
hook.run(
[
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} "
f"ON CLUSTER {CLUSTER} SYNC",
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC",
],
)
# Отсутствие таблиц проверяют обе задачи — перед созданием и после уборки,
# — поэтому у этой проверки своё имя, а остальные живут прямо в теле задач.
def _assert_tables_absent(client) -> None:
def _assert_tables_absent(hook: ClickHouseHook) -> None:
for node_name, source in NODES:
remaining = _table_engines(client, source)
remaining = _table_engines(hook, source)
if remaining:
raise RuntimeError(
f"служебные таблицы остались на {node_name}: {remaining}"
@@ -118,11 +104,11 @@ def _assert_tables_absent(client) -> None:
def test_clickhouse():
@task
def prepare_tables() -> None:
client = _clickhouse_client()
try:
_drop_tables(client)
_assert_tables_absent(client)
client.command(
hook = _clickhouse_hook()
_drop_tables(hook)
_assert_tables_absent(hook)
hook.run(
[
f"""
CREATE TABLE default.{LOCAL_TABLE} ON CLUSTER {CLUSTER}
(
@@ -133,9 +119,7 @@ def test_clickhouse():
'{{replica}}'
)
ORDER BY marker
"""
)
client.command(
""",
f"""
CREATE TABLE default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER}
AS default.{LOCAL_TABLE}
@@ -145,86 +129,77 @@ def test_clickhouse():
'{LOCAL_TABLE}',
cityHash64(marker)
)
"""
)
for node_name, source in NODES:
actual_tables = _table_engines(client, source)
if actual_tables != EXPECTED_TABLES:
raise RuntimeError(
f"неверный набор таблиц на {node_name}: {actual_tables}"
)
finally:
client.close()
""",
],
)
for node_name, source in NODES:
actual_tables = _table_engines(hook, source)
if actual_tables != EXPECTED_TABLES:
raise RuntimeError(
f"неверный набор таблиц на {node_name}: {actual_tables}"
)
@task
def write_marker() -> dict[str, str]:
client = _clickhouse_client()
try:
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
client.insert(
f"default.{LOCAL_TABLE}",
[[marker]],
column_names=["marker"],
)
local_rows = client.query(
f"""
SELECT hostName(), marker
FROM default.{LOCAL_TABLE}
WHERE marker = {{marker:String}}
""",
parameters={"marker": marker},
).result_rows
if len(local_rows) != 1 or local_rows[0][1] != marker:
raise RuntimeError(f"маркер не найден в локальной таблице: {marker}")
return {"marker": marker, "hostname": local_rows[0][0]}
finally:
client.close()
hook = _clickhouse_hook()
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
hook.bulk_insert_rows(
f"default.{LOCAL_TABLE}",
[[marker]],
column_names=["marker"],
)
local_rows = hook.get_records(
f"""
SELECT hostName(), marker
FROM default.{LOCAL_TABLE}
WHERE marker = {{marker:String}}
""",
parameters={"marker": marker},
)
if len(local_rows) != 1 or local_rows[0][1] != marker:
raise RuntimeError(f"маркер не найден в локальной таблице: {marker}")
return {"marker": marker, "hostname": local_rows[0][0]}
@task
def read_from_node_2(written: dict[str, str]) -> None:
client = _clickhouse_client()
try:
node_2_rows = client.query(
"""
SELECT hostName()
FROM remote(
'clickhouse-02:9000', 'system', 'one',
{remote_user:String}, {remote_password:String}
)
""",
parameters=_remote_parameters(),
).result_rows
if len(node_2_rows) != 1:
raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}")
distributed_rows = client.query(
f"""
SELECT _shard_num, hostName(), marker
FROM remote(
'clickhouse-02:9000',
'default',
'{DISTRIBUTED_TABLE}',
{{remote_user:String}},
{{remote_password:String}}
)
WHERE marker = {{marker:String}}
""",
parameters={
"marker": written["marker"],
**_remote_parameters(),
},
).result_rows
if written["hostname"] == node_2_rows[0][0]:
raise RuntimeError(
"запись и чтение маркера должны выполняться с разных нод"
)
expected_rows = [(1, written["hostname"], written["marker"])]
if distributed_rows != expected_rows:
raise RuntimeError(
"нода 2 не прочитала маркер первого шарда через Distributed: "
f"{written['marker']}, получено {distributed_rows}"
)
finally:
client.close()
hook = _clickhouse_hook()
node_2_rows = hook.get_records(
"""
SELECT hostName()
FROM remote(
'clickhouse-02:9000', 'system', 'one',
{remote_user:String}, {remote_password:String}
)
""",
parameters=_remote_parameters(),
)
if len(node_2_rows) != 1:
raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}")
distributed_rows = hook.get_records(
f"""
SELECT _shard_num, hostName(), marker
FROM remote(
'clickhouse-02:9000',
'default',
'{DISTRIBUTED_TABLE}',
{{remote_user:String}},
{{remote_password:String}}
)
WHERE marker = {{marker:String}}
""",
parameters={
"marker": written["marker"],
**_remote_parameters(),
},
)
if written["hostname"] == node_2_rows[0][0]:
raise RuntimeError("запись и чтение маркера должны идти с разных нод")
expected_rows = [(1, written["hostname"], written["marker"])]
if distributed_rows != expected_rows:
raise RuntimeError(
"нода 2 не прочитала маркер первого шарда через Distributed: "
f"{written['marker']}, получено {distributed_rows}"
)
# Уборка идёт только после успеха: упавший пробник оставляет кластер таким,
# каким сломался, а остатки сносит начало следующего запуска. Правило
@@ -233,12 +208,9 @@ def test_clickhouse():
# с упавшей проверкой (ADR 0003).
@task
def cleanup_tables() -> None:
client = _clickhouse_client()
try:
_drop_tables(client)
_assert_tables_absent(client)
finally:
client.close()
hook = _clickhouse_hook()
_drop_tables(hook)
_assert_tables_absent(hook)
prepared = prepare_tables()
written = write_marker()
+43 -89
View File
@@ -18,10 +18,12 @@ import time
from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.providers.docker.hooks.docker import DockerHook
from airflow.providers.docker.operators.docker import DockerOperator
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
from airflow.sdk import Connection, Variable, dag, task
from airflow.sdk import Variable, chain, dag, task
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
TAGS = ["жизненный цикл мира"]
@@ -34,7 +36,8 @@ KAFKA_BOOTSTRAP_SERVERS = os.environ["KAFKA_BOOTSTRAP_SERVERS"]
GENERATOR_IMAGE = os.environ["GENERATOR_IMAGE"]
STAND_NETWORK = os.environ["STAND_NETWORK"]
SQL_ROOT = Path("/opt/airflow/sql/ddl")
SQL_ROOT = Path("/opt/airflow/sql")
DDL_ROOT = SQL_ROOT / "ddl"
WORLD_ROOT = Path("/opt/airflow/world")
INVENTORY = WORLD_ROOT / "data/world-inventory.json"
CLICKHOUSE_CONNECTION = "clickhouse_lifecycle"
@@ -44,48 +47,9 @@ CONSUMER_GROUPS = ("clickstream_hits", "clickstream_orders")
TOPICS = {HITS_TOPIC: 2, ORDERS_TOPIC: 1}
def _clickhouse_client():
"""Открыть соединение владельца жизненного цикла."""
import clickhouse_connect
connection = Connection.get(CLICKHOUSE_CONNECTION)
return clickhouse_connect.get_client(
host=connection.host,
port=connection.port,
username=connection.login,
password=connection.password,
database=connection.schema or "default",
connect_timeout=5,
# `ON CLUSTER` ждёт хосты до 180 с. Транспорт живёт дольше, чтобы
# ClickHouse сам назвал незавершённый хост вместо сетевого тайм-аута.
send_receive_timeout=300,
)
def _ddl_statements(text: str) -> list[str]:
"""Разделить канонический DDL по его явному правилу файлов.
ClickHouse Connect исполняет один запрос за вызов. В `sql/ddl` конец
выражения — точка с запятой в конце строки; это проверяемая конвенция
наших файлов, а не попытка написать общий SQL-парсер.
"""
statements: list[str] = []
lines: list[str] = []
for line in text.splitlines():
lines.append(line)
stripped = line.rstrip()
if stripped.endswith(";") and not stripped.lstrip().startswith("--"):
statement = "\n".join(lines).strip()
statements.append(statement[:-1].rstrip())
lines = []
remainder = "\n".join(lines).strip()
if remainder and any(
line.strip() and not line.lstrip().startswith("--")
for line in remainder.splitlines()
):
raise AirflowException("DDL-файл оканчивается незавершённым выражением")
return statements
def _clickhouse_hook() -> ClickHouseHook:
"""Подключить владельца жизненного цикла."""
return ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
def _generator(task_id: str, command: list[str]) -> DockerOperator:
@@ -118,6 +82,7 @@ def _kafka_error_code(error: Exception) -> int:
start_date=START_DATE,
is_paused_upon_creation=False,
max_active_runs=1,
template_searchpath=str(SQL_ROOT),
tags=TAGS,
)
def world_initialize():
@@ -194,23 +159,6 @@ def world_initialize():
"выполните make rebuild-storage"
)
@task
def apply_ddl() -> None:
"""Применить канонические файлы DDL с ноды 1 по порядку имён."""
files = sorted(SQL_ROOT.glob("*.sql"))
if not files:
raise AirflowException(f"в {SQL_ROOT} нет файлов DDL")
client = _clickhouse_client()
try:
for path in files:
statements = _ddl_statements(path.read_text(encoding="utf-8"))
logging.info("применяем %s: выражений %s", path.name, len(statements))
for statement in statements:
client.command(statement)
finally:
client.close()
@task
def build_generator_image() -> None:
"""Собрать генератор из текущего дерева, а не из старого образа."""
@@ -248,22 +196,19 @@ def world_initialize():
+ (SELECT count() FROM ods.event_errors_dist)
"""
client = _clickhouse_client()
try:
arrived = 0
for attempt in range(1, 101):
row = client.query(query).first_row
if row is None:
raise AirflowException("ClickHouse не вернул счётчик приёма")
arrived = int(row[0])
if arrived >= expected:
logging.info("стартовый мир принят: строк %s", arrived)
return
if attempt % 5 == 0:
logging.info("принято %s из %s строк", arrived, expected)
time.sleep(3)
finally:
client.close()
hook = _clickhouse_hook()
arrived = 0
for attempt in range(1, 101):
row = hook.get_first(query)
if row is None:
raise AirflowException("ClickHouse не вернул счётчик приёма")
arrived = int(row[0])
if arrived >= expected:
logging.info("стартовый мир принят: строк %s", arrived)
return
if attempt % 5 == 0:
logging.info("принято %s из %s строк", arrived, expected)
time.sleep(3)
raise AirflowException(
f"стартовый мир не принят за 300 с: строк {arrived} из {expected}; "
"проверьте журнал send_initial_events и "
@@ -279,7 +224,19 @@ def world_initialize():
route = choose_path()
ready = world_already_exists()
topics = create_topics()
ddl = apply_ddl()
ddl_files = sorted(DDL_ROOT.glob("*.sql"))
if not ddl_files:
raise AirflowException(f"в {DDL_ROOT} нет файлов DDL")
ddl_tasks = [
SQLExecuteQueryOperator(
task_id=f"apply_{path.stem.replace('-', '_')}",
conn_id=CLICKHOUSE_CONNECTION,
sql=f"ddl/{path.name}",
split_statements=True,
do_xcom_push=False,
)
for path in ddl_files
]
image = build_generator_image()
events = _generator(
"send_initial_events",
@@ -307,7 +264,7 @@ def world_initialize():
position = remember_starting_position()
route >> [ready, topics]
topics >> ddl >> image >> events >> arrived >> snapshots >> orders >> position
chain(topics, *ddl_tasks, image, events, arrived, snapshots, orders, position)
@dag(
@@ -328,16 +285,13 @@ def world_recreate():
@task
def drop_application_databases() -> None:
client = _clickhouse_client()
try:
for database in APPLICATION_DATABASES:
logging.info("удаляем базу %s", database)
client.command(
f"DROP DATABASE IF EXISTS {database} "
f"ON CLUSTER {CLICKHOUSE_CLUSTER} SYNC"
)
finally:
client.close()
hook = _clickhouse_hook()
for database in APPLICATION_DATABASES:
logging.info("удаляем базу %s", database)
hook.run(
f"DROP DATABASE IF EXISTS {database} "
f"ON CLUSTER {CLICKHOUSE_CLUSTER} SYNC"
)
@task
def delete_consumer_groups() -> None:
+24 -11
View File
@@ -32,13 +32,18 @@ JSON-файл отдельного тома `airflow_auth`. Новый имен
Airflow получает два подготовленных подключения к `clickhouse-01`: рабочее
`clickhouse_default` и административное `clickhouse_lifecycle` для дагов
жизненного цикла мира ([ADR 0013](0013-world-lifecycle.md)). Superset получает
подключение `clickhousedb://` к `clickhouse-02`. Провайдер ClickHouse для
Airflow не нужен: локальный образ содержит прямой клиент.
Пробник создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через
`remote` читает `Distributed` на второй. Разные ноды сохраняют учебную
ловушку: забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow
на первой ноде прошла успешно.
жизненного цикла мира ([ADR 0013](0013-world-lifecycle.md)). Оба имеют тип
`clickhouse`, который регистрирует официальный провайдер
`apache-airflow-providers-clickhousedb`. SQL-файлы исполняет общий
`SQLExecuteQueryOperator`, а программные запросы идут через `ClickHouseHook`.
Сам провайдер работает через `clickhouse-connect`; его версия закреплена
отдельно, чтобы сборка не меняла транспорт незаметно.
Superset получает подключение `clickhousedb://` к `clickhouse-02`. Пробник
Airflow создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через
`remote` читает `Distributed` на второй. Разные ноды сохраняют учебную ловушку:
забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow на первой
ноде прошла успешно.
Клиент Kafka также добавлен прямо в образ Airflow. Официальный провайдер
использует тот же `confluent-kafka`, а пробнику не нужны его подключение,
@@ -84,10 +89,18 @@ Prometheus читает встроенные точки метрик двух с
Оттуда взяты `airflow.sdk`, обязательный отдельный обработчик DAG, возможность
не запускать triggerer и проверка конкретных компонентов здоровья.
Для интеграционных пробников проверены `Connection.get` в публичном
`airflow.sdk`, запросы через `clickhouse-connect` и состав официального
провайдера Kafka. Выбран прямой `confluent-kafka`: провайдер строит свои
подключения и обёртки поверх него, которые двум коротким пробникам не нужны.
Для ClickHouse через Context7 и официальную документацию Airflow проверены
`ClickHouseHook`, тип подключения `clickhouse` и работа
`SQLExecuteQueryOperator` с SQL-файлом, параметрами и несколькими выражениями.
Закреплён `apache-airflow-providers-clickhousedb==1.0.0`: на 21 августа
2026 года это последний стабильный выпуск; `1.0.1` опубликован только как
предварительная версия. Зависимость провайдера допускает любой
`clickhouse-connect>=1.3.0`, поэтому прежний `clickhouse-connect==1.6.0`
оставлен прямой зависимостью образа.
Для Kafka проверен состав официального провайдера. Выбран прямой
`confluent-kafka`: провайдер строит свои подключения и обёртки поверх него,
которые двум коротким пробникам не нужны.
Драйверы и строки подключения проверены по документации Superset 6.1.0:
[подключения к базам](https://superset.apache.org/user-docs/6.1.0/databases/),
+17 -9
View File
@@ -39,24 +39,27 @@
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
Запрос забора лежит в [`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
даг задаёт порядок и параметры, а преобразование остаётся в SQL своего слоя.
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
пишет годные строки в `ods.order`, второй — брак в `ods.order_errors`.
Транзакции между запросами нет. При частичном сбое Airflow повторяет весь
`task_id`; одинаковые исходные строки и служебные метки не вычисляются заново.
Запросы лежат рядом с целями:
[`order_load.sql`](../../../sql/ods/order_load.sql) и
[`order_errors_load.sql`](../../../sql/ods/order_errors_load.sql).
Обе вставки лежат в одном самодостаточном файле
[`order_parse.sql`](../../../sql/ods/order_parse.sql). Airflow сначала
шаблонизирует файл, затем делит получившийся SQL на выражения и исполняет их по
порядку в одной задаче `parse_batch`.
Условия запросов взаимодополняющие: один общий предикат определяет брак, а
годная ветвь использует его буквальное отрицание. Все функции предиката
Условия запросов взаимодополняющие: один общий предикат определяет годную
строку, а ветвь брака использует его буквальное отрицание. Все функции предиката
возвращают результат без исключения, а сам предикат всегда заканчивается в
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
этого не нужен. Обе ветви включают один файл
[`_order_wire_contract.sql`](../../../sql/ods/_order_wire_contract.sql), поэтому
предикат нельзя случайно исправить только в одной из них.
этого не нужен. Предикат объявлен локальным макросом Jinja внутри
`order_parse.sql` и разворачивается в оба выражения. Макрос убирает
дублирование, но не вводит отдельный фрагмент, который нельзя исполнить
самостоятельно.
## Граница строгого приёма
@@ -178,6 +181,11 @@ JSON-объектом с точным набором ключей: `order_id`, `
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь
`5490.00 − 823.50 + 299.00 = 4965.50`.
При исполнении #124 21 августа 2026 года адресная пересборка мира прошла через
`SQLExecuteQueryOperator`: `pull_batch` и `parse_batch` завершились успешно. В
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
пользователя `etl` для забора в STG, записи годных строк и записи брака.
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR
+17 -13
View File
@@ -450,16 +450,18 @@ kafka_offset)`: смотрят такую таблицу от класса, а
Исполняемые дагами запросы лежат по правилу
`sql/<слой>/<объект>_<роль>.sql`. Слой — слой цели запроса: забор заказов в
сырьё живёт в `sql/stg/orders_raw_load.sql`, две ветви разбора — в `sql/ods/`.
Даг называет файлы, передаёт параметры и задаёт порядок выполнения, но не
хранит текст преобразований. Общий фрагмент начинается с подчёркивания: файл
`sql/ods/_order_wire_contract.sql` сам не исполняется, его включают обе ветви
разбора.
сырьё живёт в `sql/stg/orders_raw_load.sql`, разбор — в
`sql/ods/order_parse.sql`. Даг называет файлы, передаёт параметры и задаёт
порядок выполнения, но не хранит текст преобразований. Два выражения разбора
делят локальный макрос Jinja в том же файле; подключаемого SQL-фрагмента между
ними нет.
Airflow читает и собирает эти файлы штатным шаблонизатором при исполнении
задачи. Каталог `sql/` смонтирован во все его службы только для чтения. Поэтому
обработчик дагов не зависит от наличия SQL на машине при разборе Python, а
планировщик видит те же файлы при исполнении задачи.
SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода
обработчик DAG читает список DDL-файлов, загружает содержимое каждого
названного SQL-файла и кладёт его в сериализованный граф. При исполнении Airflow
разворачивает в этом тексте Jinja; параметры ClickHouse передаются отдельно и
не подставляются строкой. Каталог `sql/` смонтирован во все службы Airflow
только для чтения.
### DDL
@@ -489,8 +491,10 @@ Airflow читает и собирает эти файлы штатным шаб
отладке.
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
`lifecycle`: всё `ON CLUSTER`, кроме чтеца заказов — он объявлен только на этой
ноде. Перед DDL даг создаёт `hits` с двумя партициями и `orders` с одной
`lifecycle`: один файл — одна последовательная задача
`SQLExecuteQueryOperator`, а выражения внутри разделяет сам оператор. Всё
`ON CLUSTER`, кроме чтеца заказов — он объявлен только на этой ноде. Перед DDL
даг создаёт `hits` с двумя партициями и `orders` с одной
([ADR 0008](../adr/0008-order-ingestion.md)). Этот порядок не даёт `hits`
автоматически создаться с одной партицией. Брокер автосоздание разрешает, а
потребитель librdkafka внутри ClickHouse его не просит. Оба конца измерены;
@@ -532,8 +536,8 @@ Airflow читает и собирает эти файлы штатным шаб
| `dic` | `dic.products` | словарь товаров поверх подложки |
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два
`INSERT SELECT` шага `parse_batch` из файлов `sql/ods/order_load.sql` и
`sql/ods/order_errors_load.sql`.
`INSERT SELECT` одной задачи `parse_batch` из файла
`sql/ods/order_parse.sql`.
Слой DDS и слой DM появляются на следующих этапах; их состав задан разделом
7 мастер-спеки и переносится сюда по мере постройки.
+15
View File
@@ -235,6 +235,21 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
## Что проверено
**Перезамер 21 августа 2026 года при исполнении #124.** Образ Airflow собран с
`apache-airflow-providers-clickhousedb==1.0.0`,
`apache-airflow-providers-common-sql==2.0.1` и
`clickhouse-connect==1.6.0`; `pip check` не нашёл несовместимых зависимостей.
Адресная пересборка мира через `make rebuild-storage` заняла 3 м 5 с: все шесть
задач DDL, `pull_batch` и `parse_batch` завершились успешно. После неё прошли
смоук 21/21 за 17 с, проверка ClickHouse 10/10 и межсервисная проверка 7/7 за
100 с.
Первый `make up` после замены образа попал в короткий рассинхрон запуска
Airflow: API уже сообщил о здоровье и отдал `world_initialize` по GET, но POST
нового запуска ещё ответил `Dag with dag_id not found`. Ошибок импорта не было;
через несколько секунд повторный `make up` завершился успешно и не менял
готовый мир.
**Перезамер 21 августа 2026 года при исполнении #114.** После добавления
`kafka-exporter` и источника ClickHouse смоук прошёл 21/21 за 16–17 секунд,
`check-services` — 7/7 за 93 секунды. При остановленном экспортёре смоук дал
+11 -7
View File
@@ -51,8 +51,8 @@ Airflow держит прикладные топики Kafka, каноничес
([ADR 0007](../adr/0007-clickhouse-access.md)). Ей разрешено администрировать
прикладные базы — заводить и удалять их и объекты внутри, — читать их и
выполнять кластерный DDL. Ей разрешено чтение `system.settings`: без него
`clickhouse-connect` не открывает соединение. Административные запросы к
системным базам ей запрещены.
драйвер `clickhouse-connect` под официальным провайдером Airflow не открывает
соединение. Административные запросы к системным базам ей запрещены.
`etl` при этом не расширяется. Право удаления баз нужно редкой операции
восстановления и остаётся у `lifecycle`. Граница проверяема: при лишнем запросе
@@ -69,9 +69,10 @@ Airflow держит прикладные топики Kafka, каноничес
Меняющиеся API сверены через Context7 и затем с установленными пакетами
стенда. Выбраны публичный REST API v2 и ждущий `TriggerDagRunOperator` Airflow,
сборка образа через `DockerHook.get_conn().build()`, futures `AdminClient` для
Kafka и по одному `clickhouse-connect.command()` на выражение DDL. Последний
выбор требует явного разбора файлов: клиент сам многозапросный файл не делит,
а в каноническом SQL конец выражения уже задан точкой с запятой в конце строки.
Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов ClickHouse и
`ClickHouseHook` для запросов, чей результат обрабатывает Python. Оператор сам
шаблонизирует файл и при `split_statements=True` исполняет выражения по порядку;
собственный разбор SQL дагу больше не нужен.
## Контракты дагов
@@ -90,8 +91,11 @@ Kafka и по одному `clickhouse-connect.command()` на выражени
матвью, и топик, созданный брокером сам, приехал бы с одним разделом
([ADR 0008](../adr/0008-order-ingestion.md)).
3. Применяет канонический DDL из `sql/ddl/` по порядку имён файлов, с ноды 1,
учётной записью `lifecycle`. Правило «матвью приёма создаётся последней»
остаётся за раскладкой файлов и не переезжает в даг.
учётной записью `lifecycle`. Каждый файл — отдельная последовательная
задача `SQLExecuteQueryOperator`; выражения внутри неё исполняются по
порядку с `split_statements=True`. Красная задача называет файл и не пускает
последующие. Правило «матвью приёма создаётся последней» остаётся за
раскладкой файлов и не переезжает в даг.
4. Собирает канонический образ генератора. Иначе мир мог бы приехать из старого
кода: сборкой образа при подъёме больше никто не занимается.
5. Играет стартовые дни одним прогоном `batch` того же образа.
+6 -1
View File
@@ -2,14 +2,19 @@ FROM apache/airflow:3.3.0
USER root
# SQL-файлы ClickHouse исполняет официальный провайдер Airflow. Он разрешает
# любую версию clickhouse-connect >= 1.3.0, поэтому драйвер закреплён отдельно:
# сборка стенда не должна незаметно менять транспорт при обновлении зависимостей.
#
# Официальный провайдер Kafka использует тот же confluent-kafka. Пробникам
# не нужны его подключения и обёртки, поэтому в образ добавлен сам клиент.
#
# Провайдера docker, которым пульт мира зовёт генератор, ставить не нужно:
# база несёт его сама — 4.5.7 к Airflow 3.3.0, замер 13 августа 2026 года.
RUN uv pip install --python /home/airflow/.local/bin/python --no-cache \
"apache-airflow-providers-clickhousedb==1.0.0" \
"clickhouse-connect==1.6.0" \
"confluent-kafka==2.15.0" \
&& python -c "import clickhouse_connect, confluent_kafka"
&& python -c "import airflow.providers.clickhousedb, clickhouse_connect, confluent_kafka"
USER airflow
+8 -3
View File
@@ -22,21 +22,26 @@ PY
runuser -u airflow -- airflow db migrate
runuser -u airflow -- airflow connections delete clickhouse_default >/dev/null 2>&1 || true
# Прикладные запросы короткие: 30 с отличают зависший транспорт от работы запроса.
runuser -u airflow -- airflow connections add clickhouse_default \
--conn-type generic \
--conn-type clickhouse \
--conn-host clickhouse-01 \
--conn-port 8123 \
--conn-login etl \
--conn-password "$CLICKHOUSE_ETL_PASSWORD" \
--conn-schema default \
--conn-description "ClickHouse, нода 1; типизированный провайдер появится на этапе ETL"
--conn-extra '{"connect_timeout": 5, "send_receive_timeout": 30}' \
--conn-description "ClickHouse, нода 1; запросы прикладных дагов"
runuser -u airflow -- airflow connections delete clickhouse_lifecycle >/dev/null 2>&1 || true
# `ON CLUSTER` ждёт хосты до 180 с. Транспорт живёт дольше, чтобы ClickHouse
# успел назвать незавершённый хост вместо сетевого тайм-аута.
runuser -u airflow -- airflow connections add clickhouse_lifecycle \
--conn-type generic \
--conn-type clickhouse \
--conn-host clickhouse-01 \
--conn-port 8123 \
--conn-login lifecycle \
--conn-password "$CLICKHOUSE_LIFECYCLE_PASSWORD" \
--conn-schema default \
--conn-extra '{"connect_timeout": 5, "send_receive_timeout": 300}' \
--conn-description "ClickHouse, нода 1; создание и пересоздание прикладного мира"
+16 -3
View File
@@ -204,6 +204,7 @@ check_airflow() {
local dag
local dag_id
local health
local lifecycle_connection
local password
local response
local user
@@ -253,21 +254,33 @@ check_airflow() {
-H "Authorization: Bearer ${airflow_token}" \
"http://127.0.0.1:${airflow_port}/api/v2/connections/clickhouse_default" \
2>/dev/null || true)"
lifecycle_connection="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${airflow_token}" \
"http://127.0.0.1:${airflow_port}/api/v2/connections/clickhouse_lifecycle" \
2>/dev/null || true)"
if jq -e '
.connection_id == "clickhouse_default" and
.conn_type == "clickhouse" and
.host == "clickhouse-01" and
.port == 8123 and
.login == "etl" and
.schema == "default"
' >/dev/null 2>&1 <<<"$connection" && \
' >/dev/null 2>&1 <<<"$connection" && jq -e '
.connection_id == "clickhouse_lifecycle" and
.conn_type == "clickhouse" and
.host == "clickhouse-01" and
.port == 8123 and
.login == "lifecycle" and
.schema == "default"
' >/dev/null 2>&1 <<<"$lifecycle_connection" && \
timeout 20s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
exec -T airflow-scheduler \
curl -sf -u "etl:${clickhouse_password}" \
'http://clickhouse-01:8123/?query=SELECT%201' \
2>/dev/null | grep -qx '1'; then
pass 'подготовленное подключение Airflow указывает на clickhouse-01, нода доступна из контейнера'
pass 'оба подключения Airflow имеют тип clickhouse, нода 1 доступна из контейнера'
else
fail 'подключение Airflow не указывает на clickhouse-01 или нода недоступна из контейнера'
fail 'тип или адрес подключений Airflow отличается от ожидаемого либо нода 1 недоступна из контейнера'
fi
}
-61
View File
@@ -1,61 +0,0 @@
-- Форма провода одной строки слепка: общая часть обеих вставок ODS.
--
-- Строгий приём: проверяется форма провода и ничего сверх неё.
--
-- Ключей одиннадцать, и проверяются все: десять скалярных значений прямо
-- образуют типизированную строку заказа. Внутрь items приём не смотрит —
-- содержимое позиций, переходы статуса и равенства сумм остаются ниже границы
-- (docs/architecture/orders/ingestion.md, «Граница строгого приёма»).
--
-- Файл включают обе вставки нарочно: годная ветвь берёт row_is_valid, брак —
-- буквальное NOT. Разойдись условия хоть на символ — строка либо задвоится,
-- либо исчезнет молча.
--
-- Отсюда два запрета на выражения предиката, и оба серьёзные. Первый: ни одно
-- не возвращает NULL — трёхзначная логика дала бы строку, которую не берёт ни
-- условие, ни его отрицание. Поэтому сравнения дают 0 или 1, а обнуляемый
-- разбор заканчивается IS NOT NULL. Второй: ни одно не бросает исключений на
-- произвольном raw — иначе одна грязная строка роняет весь переход, ради
-- отсутствия чего таблица брака и заведена.
WITH
-- Каноническое время провода: UTC, ровно три знака долей секунды.
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'' AS ts_mask,
JSONType(raw) = 'Object' AS is_object,
arraySort(JSONExtractKeys(raw)) = arraySort([
'order_id', 'user_id', 'status', 'created_at', 'updated_at',
'items_total', 'discount', 'delivery', 'total', 'items',
'snapshot_date'
]) AS keys_match,
JSONType(raw, 'order_id') = 'String'
AND JSONType(raw, 'status') = 'String'
-- Целое у JSONType зовётся двумя именами, и нужны оба: с одним
-- Int64 законный идентификатор за 2^63 уехал бы в брак.
AND JSONType(raw, 'user_id') IN ('Int64', 'UInt64')
AND JSONExtract(raw, 'user_id', 'Nullable(UInt64)') IS NOT NULL
AND JSONType(raw, 'items') = 'Array'
-- Форму держит регулярка, реальность — разбор, и порознь они дырявы:
-- маска берёт «2026-6-3T…» без ведущих нулей, а регулярка пропускает
-- 30 февраля. Обе половины измерены — docs/architecture/storage.md,
-- «Что проверено».
AND arrayAll(k ->
JSONType(raw, k) = 'String'
AND match(JSONExtractString(raw, k),
'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$')
AND parseDateTime64InJodaSyntaxOrNull(
JSONExtractString(raw, k), ts_mask, 'UTC') IS NOT NULL,
['created_at', 'updated_at'])
-- Деньги — строка с ровно двумя знаками после точки. Регулярка держит
-- форму, разбор — вместимость: у строки из двадцати девяток форма
-- хороша, а в Decimal(18, 2) она не влезает.
AND arrayAll(k ->
JSONType(raw, k) = 'String'
AND match(JSONExtractString(raw, k), '^\\d+\\.\\d{2}$')
AND toDecimal64OrNull(JSONExtractString(raw, k), 2) IS NOT NULL,
['items_total', 'discount', 'delivery', 'total'])
AND JSONType(raw, 'snapshot_date') = 'String'
AND match(JSONExtractString(raw, 'snapshot_date'), '^\\d{4}-\\d{2}-\\d{2}$')
AND parseDateTimeInJodaSyntaxOrNull(
JSONExtractString(raw, 'snapshot_date'), 'yyyy-MM-dd', 'UTC') IS NOT NULL
AS fields_valid,
is_object AND keys_match AND fields_valid AS row_is_valid
-25
View File
@@ -1,25 +0,0 @@
-- Брак: тот же срез и буквальное отрицание того же предиката.
--
-- Классы перекрываются, поэтому проверяются по порядку, а в error_class идёт
-- первый совпавший: скаляр проваливает и проверку на объект, и сверку ключей —
-- без объявленного порядка он попал бы то в один класс, то в другой.
INSERT INTO ods.order_errors_dist
{% include "ods/_order_wire_contract.sql" %}
SELECT
raw,
multiIf(
NOT is_object, 'not_an_object',
NOT keys_match, 'keyset_mismatch',
'field_invalid'
) AS error_class,
kafka_topic,
kafka_partition,
kafka_offset,
kafka_timestamp,
consumer_host,
_load_id,
_load_ts
FROM stg.orders_raw_dist
WHERE _load_id = {load_id:String} AND NOT row_is_valid
SETTINGS distributed_foreground_insert = 1
-33
View File
@@ -1,33 +0,0 @@
-- Годные версии заказа. Срез читается по _load_id — тому же, что проставил
-- забор: разбор идёт по неизменной порции, а не по «всему, что появилось».
--
-- Разобранные значения берёт assumeNotNull: обнуляемый разбор стоит за
-- предикатом, который NULL уже отсёк, и приведение здесь не может упасть.
INSERT INTO ods.order_dist
{% include "ods/_order_wire_contract.sql" %}
SELECT
JSONExtractString(raw, 'order_id') AS order_id,
JSONExtract(raw, 'user_id', 'UInt64') AS user_id,
JSONExtractString(raw, 'status') AS status,
assumeNotNull(parseDateTime64InJodaSyntaxOrNull(
JSONExtractString(raw, 'created_at'), ts_mask, 'UTC')) AS created_at,
assumeNotNull(parseDateTime64InJodaSyntaxOrNull(
JSONExtractString(raw, 'updated_at'), ts_mask, 'UTC')) AS updated_at,
assumeNotNull(toDecimal64OrNull(
JSONExtractString(raw, 'items_total'), 2)) AS items_total,
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'discount'), 2)) AS discount,
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'delivery'), 2)) AS delivery,
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'total'), 2)) AS total,
-- items кладётся сырым фрагментом JSON, а не разобранной структурой.
JSONExtractRaw(raw, 'items') AS items,
toDate(assumeNotNull(parseDateTimeInJodaSyntaxOrNull(
JSONExtractString(raw, 'snapshot_date'),
'yyyy-MM-dd', 'UTC'))) AS snapshot_date,
-- Метки запуска и прибытия переносятся как есть. Поставь здесь now64(3) —
-- и _load_ts молча ответила бы на другой вопрос: «когда разобрали».
_load_id,
_load_ts
FROM stg.orders_raw_dist
WHERE _load_id = {load_id:String} AND row_is_valid
SETTINGS distributed_foreground_insert = 1
+121
View File
@@ -0,0 +1,121 @@
-- Разбор одного среза заказов: сначала годные версии, затем брак.
--
-- Обе вставки должны делить один предикат. Локальный макрос Jinja
-- разворачивает его в каждое выражение до того, как оператор разделит файл по
-- точкам с запятой. Так контракт остаётся рядом с запросами и не становится
-- третьим SQL-файлом, который нельзя исполнить самостоятельно.
--
-- Строгий приём проверяет форму провода и ничего сверх неё. Ключей одиннадцать,
-- и проверяются все: десять скалярных значений прямо образуют типизированную
-- строку заказа. Внутрь items приём не смотрит — содержимое позиций, переходы
-- статуса и равенства сумм остаются ниже этой границы
-- (docs/architecture/orders/ingestion.md, «Граница строгого приёма»).
--
-- У общего предиката два обязательных свойства. Он всегда даёт 0 или 1, не
-- NULL: иначе строку не возьмут ни условие, ни его отрицание. И он не бросает
-- исключений на произвольном raw: одна грязная строка не должна ронять весь
-- переход, ради этого и существует таблица брака.
{% macro order_wire_contract() %}
WITH
-- Каноническое время провода: UTC, ровно три знака долей секунды.
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'' AS ts_mask,
JSONType(raw) = 'Object' AS is_object,
arraySort(JSONExtractKeys(raw)) = arraySort([
'order_id', 'user_id', 'status', 'created_at', 'updated_at',
'items_total', 'discount', 'delivery', 'total', 'items',
'snapshot_date'
]) AS keys_match,
JSONType(raw, 'order_id') = 'String'
AND JSONType(raw, 'status') = 'String'
-- Целое у JSONType зовётся двумя именами, и нужны оба: с одним
-- Int64 законный идентификатор за 2^63 уехал бы в брак.
AND JSONType(raw, 'user_id') IN ('Int64', 'UInt64')
AND JSONExtract(raw, 'user_id', 'Nullable(UInt64)') IS NOT NULL
AND JSONType(raw, 'items') = 'Array'
-- Форму держит регулярка, реальность — разбор, и порознь они дырявы:
-- маска берёт «2026-6-3T…» без ведущих нулей, а регулярка пропускает
-- 30 февраля. Обе половины измерены — docs/architecture/storage.md,
-- «Что проверено».
AND arrayAll(k ->
JSONType(raw, k) = 'String'
AND match(JSONExtractString(raw, k),
'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$')
AND parseDateTime64InJodaSyntaxOrNull(
JSONExtractString(raw, k), ts_mask, 'UTC') IS NOT NULL,
['created_at', 'updated_at'])
-- Деньги — строка с ровно двумя знаками после точки. Регулярка держит
-- форму, разбор — вместимость: у строки из двадцати девяток форма
-- хороша, а в Decimal(18, 2) она не влезает.
AND arrayAll(k ->
JSONType(raw, k) = 'String'
AND match(JSONExtractString(raw, k), '^\\d+\\.\\d{2}$')
AND toDecimal64OrNull(JSONExtractString(raw, k), 2) IS NOT NULL,
['items_total', 'discount', 'delivery', 'total'])
AND JSONType(raw, 'snapshot_date') = 'String'
AND match(JSONExtractString(raw, 'snapshot_date'), '^\\d{4}-\\d{2}-\\d{2}$')
AND parseDateTimeInJodaSyntaxOrNull(
JSONExtractString(raw, 'snapshot_date'), 'yyyy-MM-dd', 'UTC') IS NOT NULL
AS fields_valid,
is_object AND keys_match AND fields_valid AS row_is_valid
{% endmacro %}
-- Годные версии заказа. Срез читается по _load_id — тому же, что проставил
-- забор: разбор идёт по неизменной порции, а не по «всему, что появилось».
--
-- Разобранные значения берёт assumeNotNull: обнуляемый разбор стоит за
-- предикатом, который NULL уже отсёк, и приведение здесь не может упасть.
INSERT INTO ods.order_dist
{{ order_wire_contract() }}
SELECT
JSONExtractString(raw, 'order_id') AS order_id,
JSONExtract(raw, 'user_id', 'UInt64') AS user_id,
JSONExtractString(raw, 'status') AS status,
assumeNotNull(parseDateTime64InJodaSyntaxOrNull(
JSONExtractString(raw, 'created_at'), ts_mask, 'UTC')) AS created_at,
assumeNotNull(parseDateTime64InJodaSyntaxOrNull(
JSONExtractString(raw, 'updated_at'), ts_mask, 'UTC')) AS updated_at,
assumeNotNull(toDecimal64OrNull(
JSONExtractString(raw, 'items_total'), 2)) AS items_total,
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'discount'), 2)) AS discount,
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'delivery'), 2)) AS delivery,
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'total'), 2)) AS total,
-- items кладётся сырым фрагментом JSON, а не разобранной структурой.
JSONExtractRaw(raw, 'items') AS items,
toDate(assumeNotNull(parseDateTimeInJodaSyntaxOrNull(
JSONExtractString(raw, 'snapshot_date'),
'yyyy-MM-dd', 'UTC'))) AS snapshot_date,
-- Метки запуска и прибытия переносятся как есть. Поставь здесь now64(3) —
-- и _load_ts молча ответила бы на другой вопрос: «когда разобрали».
_load_id,
_load_ts
FROM stg.orders_raw_dist
WHERE _load_id = {load_id:String} AND row_is_valid
SETTINGS distributed_foreground_insert = 1;
-- Брак: тот же срез и буквальное отрицание того же предиката.
--
-- Классы перекрываются, поэтому проверяются по порядку, а в error_class идёт
-- первый совпавший: скаляр проваливает и проверку на объект, и сверку ключей —
-- без объявленного порядка он попал бы то в один класс, то в другой.
INSERT INTO ods.order_errors_dist
{{ order_wire_contract() }}
SELECT
raw,
multiIf(
NOT is_object, 'not_an_object',
NOT keys_match, 'keyset_mismatch',
'field_invalid'
) AS error_class,
kafka_topic,
kafka_partition,
kafka_offset,
kafka_timestamp,
consumer_host,
_load_id,
_load_ts
FROM stg.orders_raw_dist
WHERE _load_id = {load_id:String} AND NOT row_is_valid
SETTINGS distributed_foreground_insert = 1;