refactor(airflow): пробник ClickHouse разбит на четыре задачи
Зачем: пробник был одной задачей — в интерфейсе Airflow один красный квадрат, а место отказа приходилось искать по журналу. Ручная машинерия проброса и сведения ошибок занимала больше места, чем сама проверка, и читатель продирался через неё раньше, чем понимал, что пробник проверяет. Пробники — единственный образец DAG в стенде, по ним будут писать остальные. Что: test_clickhouse разбит на prepare_tables, write_marker, read_from_node_2 и cleanup_tables; маркер и имя принявшей запись ноды едут между задачами через XCom строками. Снято сведение ошибок: except BaseException, ExceptionGroup, add_note и накопление ошибок в список; клиент каждая задача заводит общим помощником и закрывает в finally. Ноды описаны константой NODES парами «имя для человека — источник для запроса», булев переключатель и параллельные списки подписей ушли. Уборка идёт обычным правилом запуска, а не all_done: состояние запуска Airflow считает по концам графа, и уборка, отработавшая после отказа, покрасила бы в зелёный запуск с упавшей проверкой — решение записано в ADR 0003. Комментарии остались в четырёх местах: чтение ноды 2 через remote(), импорт клиента внутри функции, правило запуска уборки и автосоздание топика в test_kafka. Малые проверки: заглушка task принимает обе формы декоратора, проверка сведения ошибок заменена проверками уборки. Красный путь ищет образец по журналам всех задач последнего запуска, а не в одном самом свежем. Проверка: make config-test, make smoke (25 проверок) и make smoke-guards зелены. Разбитый пробник укладывается в 5 секунд из 120, отведённых run_airflow_probe, — предел не трогаем. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+90
-75
@@ -10,14 +10,42 @@ from airflow.sdk import Connection, dag, get_current_context, task
|
||||
CLUSTER = "clickstream_cluster"
|
||||
LOCAL_TABLE = "airflow_probe_local"
|
||||
DISTRIBUTED_TABLE = "airflow_probe_distributed"
|
||||
EXPECTED_TABLES = [
|
||||
(DISTRIBUTED_TABLE, "Distributed"),
|
||||
(LOCAL_TABLE, "ReplicatedMergeTree"),
|
||||
]
|
||||
|
||||
# Ноду 2 пробник читает не своим подключением, а запросом remote() с ноды 1: у
|
||||
# Airflow подготовлено одно подключение — к clickhouse-01, и второго ради
|
||||
# пробника не заводят. При этом remote('clickhouse-02:9000', ...) делает
|
||||
# инициатором распределённого запроса саму ноду 2 — проверяется именно это, а
|
||||
# не доступность ноды 2 по сети. Порт 9000 — межсерверный, тогда как
|
||||
# подключение Airflow ходит по HTTP на 8123.
|
||||
NODES = (
|
||||
("ноде 1", "system.tables"),
|
||||
("ноде 2", "remote('clickhouse-02:9000', system.tables)"),
|
||||
)
|
||||
|
||||
|
||||
def _table_engines(client, *, query_node_2: bool) -> list[tuple[str, str]]:
|
||||
source = (
|
||||
"remote('clickhouse-02:9000', system.tables)"
|
||||
if query_node_2
|
||||
else "system.tables"
|
||||
def _clickhouse_client():
|
||||
# clickhouse_connect стоит только в образе Airflow, а малые проверки грузят
|
||||
# этот модуль обычным интерпретатором, где пакета нет. Импорт верхнего
|
||||
# уровня красит make config-test, поэтому он живёт здесь.
|
||||
import clickhouse_connect
|
||||
|
||||
connection = Connection.get("clickhouse_default")
|
||||
return clickhouse_connect.get_client(
|
||||
host=connection.host,
|
||||
port=connection.port,
|
||||
username=connection.login or "default",
|
||||
password=connection.password or "",
|
||||
database=connection.schema or "default",
|
||||
connect_timeout=5,
|
||||
send_receive_timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _table_engines(client, source: str) -> list[tuple[str, str]]:
|
||||
result = client.query(
|
||||
f"""
|
||||
SELECT name, engine
|
||||
@@ -41,27 +69,33 @@ def _drop_tables(client) -> None:
|
||||
|
||||
|
||||
def _assert_tables_absent(client) -> None:
|
||||
for query_node_2, node_name in ((False, "ноде 1"), (True, "ноде 2")):
|
||||
remaining = _table_engines(client, query_node_2=query_node_2)
|
||||
for node_name, source in NODES:
|
||||
remaining = _table_engines(client, source)
|
||||
if remaining:
|
||||
raise RuntimeError(
|
||||
f"служебные таблицы остались на {node_name}: {remaining}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_tables_created(client) -> None:
|
||||
for node_name, source in NODES:
|
||||
actual_tables = _table_engines(client, source)
|
||||
if actual_tables != EXPECTED_TABLES:
|
||||
raise RuntimeError(
|
||||
f"неверный набор таблиц на {node_name}: {actual_tables}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_marker_path(
|
||||
*,
|
||||
local_rows: list[tuple[str, str]],
|
||||
distributed_rows: list[tuple[int, str, str]],
|
||||
write_hostname: str,
|
||||
node_2_hostname: str,
|
||||
marker: str,
|
||||
) -> None:
|
||||
if len(local_rows) != 1 or local_rows[0][1] != marker:
|
||||
raise RuntimeError(f"маркер не найден в локальной таблице: {marker}")
|
||||
node_1_hostname = local_rows[0][0]
|
||||
if node_1_hostname == node_2_hostname:
|
||||
if write_hostname == node_2_hostname:
|
||||
raise RuntimeError("запись и чтение маркера должны выполняться с разных нод")
|
||||
expected_rows = [(1, node_1_hostname, marker)]
|
||||
expected_rows = [(1, write_hostname, marker)]
|
||||
if distributed_rows != expected_rows:
|
||||
raise RuntimeError(
|
||||
"нода 2 не прочитала маркер первого шарда через Distributed: "
|
||||
@@ -69,30 +103,6 @@ def _assert_marker_path(
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_clickhouse_client(client, original_error: BaseException | None) -> None:
|
||||
cleanup_errors: list[Exception] = []
|
||||
try:
|
||||
_drop_tables(client)
|
||||
_assert_tables_absent(client)
|
||||
except Exception as error:
|
||||
cleanup_errors.append(error)
|
||||
try:
|
||||
client.close()
|
||||
except Exception as error:
|
||||
cleanup_errors.append(error)
|
||||
|
||||
if original_error is not None:
|
||||
for error in cleanup_errors:
|
||||
original_error.add_note(
|
||||
f"Дополнительная ошибка очистки ClickHouse: {error}"
|
||||
)
|
||||
return
|
||||
if len(cleanup_errors) == 1:
|
||||
raise cleanup_errors[0]
|
||||
if cleanup_errors:
|
||||
raise ExceptionGroup("ошибки очистки ClickHouse", cleanup_errors)
|
||||
|
||||
|
||||
@dag(
|
||||
dag_id="test_clickhouse",
|
||||
schedule=None,
|
||||
@@ -103,26 +113,8 @@ def _cleanup_clickhouse_client(client, original_error: BaseException | None) ->
|
||||
)
|
||||
def test_clickhouse():
|
||||
@task
|
||||
def check_cluster_path() -> None:
|
||||
import clickhouse_connect
|
||||
|
||||
connection = Connection.get("clickhouse_default")
|
||||
client = clickhouse_connect.get_client(
|
||||
host=connection.host,
|
||||
port=connection.port,
|
||||
username=connection.login or "default",
|
||||
password=connection.password or "",
|
||||
database=connection.schema or "default",
|
||||
connect_timeout=5,
|
||||
send_receive_timeout=30,
|
||||
)
|
||||
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
|
||||
expected_tables = [
|
||||
(DISTRIBUTED_TABLE, "Distributed"),
|
||||
(LOCAL_TABLE, "ReplicatedMergeTree"),
|
||||
]
|
||||
probe_error = None
|
||||
|
||||
def prepare_tables() -> None:
|
||||
client = _clickhouse_client()
|
||||
try:
|
||||
_drop_tables(client)
|
||||
_assert_tables_absent(client)
|
||||
@@ -151,17 +143,15 @@ def test_clickhouse():
|
||||
)
|
||||
"""
|
||||
)
|
||||
_assert_tables_created(client)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
for query_node_2, node_name in ((False, "ноде 1"), (True, "ноде 2")):
|
||||
actual_tables = _table_engines(
|
||||
client,
|
||||
query_node_2=query_node_2,
|
||||
)
|
||||
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]],
|
||||
@@ -175,6 +165,16 @@ def test_clickhouse():
|
||||
""",
|
||||
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()
|
||||
|
||||
@task
|
||||
def read_from_node_2(written: dict[str, str]) -> None:
|
||||
client = _clickhouse_client()
|
||||
try:
|
||||
node_2_rows = client.query(
|
||||
"""
|
||||
SELECT hostName()
|
||||
@@ -195,21 +195,36 @@ def test_clickhouse():
|
||||
)
|
||||
WHERE marker = {{marker:String}}
|
||||
""",
|
||||
parameters={"marker": marker},
|
||||
parameters={"marker": written["marker"]},
|
||||
).result_rows
|
||||
_assert_marker_path(
|
||||
local_rows=local_rows,
|
||||
distributed_rows=distributed_rows,
|
||||
write_hostname=written["hostname"],
|
||||
node_2_hostname=node_2_rows[0][0],
|
||||
marker=marker,
|
||||
marker=written["marker"],
|
||||
)
|
||||
except BaseException as error:
|
||||
probe_error = error
|
||||
raise
|
||||
finally:
|
||||
_cleanup_clickhouse_client(client, probe_error)
|
||||
client.close()
|
||||
|
||||
check_cluster_path()
|
||||
# Уборка идёт только после успеха: упавший пробник оставляет кластер таким,
|
||||
# каким сломался, а остатки сносит начало следующего запуска. Правило
|
||||
# запуска решает здесь и то, что стенд увидит снаружи — с "all_done"
|
||||
# уборка стала бы зелёным концом графа и покрасила бы в зелёный запуск
|
||||
# с упавшей проверкой (ADR 0003).
|
||||
@task
|
||||
def cleanup_tables() -> None:
|
||||
client = _clickhouse_client()
|
||||
try:
|
||||
_drop_tables(client)
|
||||
_assert_tables_absent(client)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
prepared = prepare_tables()
|
||||
written = write_marker()
|
||||
checked = read_from_node_2(written)
|
||||
|
||||
prepared >> written >> checked >> cleanup_tables()
|
||||
|
||||
|
||||
test_clickhouse()
|
||||
|
||||
Reference in New Issue
Block a user