feat(etl): добавлен дневной конвейер трансформаций
Зачем: - Один запуск должен одинаково работать из жизненного цикла, вручную после разгона и повторно после отказа. Что: - Добавлена последовательная сцепка DDS и DM с ожиданием каждого слоя. - DDS дожидается смещений Kafka и очереди доставки ClickHouse за общий срок. - ETL встроен в создание мира и живой день; документация приведена к новому графу. Проверка: - make config-test; make lint. - make smoke: 21/21; make check-clickhouse: 10/10; make check-services: 7/7. - make rebuild-storage, живой и пакетный дни, искусственный отказ и ручное восстановление ETL.
This commit is contained in:
1 parent
fb00bbe4c3
commit
12a5c109b2
10 files changed
+359
-43
No files matched your search
+197
-1
@@ -19,6 +19,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from airflow.exceptions import AirflowException
|
from airflow.exceptions import AirflowException
|
||||||
@@ -34,6 +36,15 @@ START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
|||||||
|
|
||||||
SQL_ROOT = Path("/opt/airflow/sql")
|
SQL_ROOT = Path("/opt/airflow/sql")
|
||||||
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
||||||
|
CLICKHOUSE_CLUSTER = "clickstream_cluster"
|
||||||
|
|
||||||
|
KAFKA_BOOTSTRAP_SERVERS = os.environ["KAFKA_BOOTSTRAP_SERVERS"]
|
||||||
|
HITS_TOPIC = os.environ["KAFKA_TOPIC"]
|
||||||
|
HITS_GROUP = "clickstream_hits"
|
||||||
|
HITS_PARTITIONS = (0, 1)
|
||||||
|
|
||||||
|
READINESS_TIMEOUT_SECONDS = 300
|
||||||
|
READINESS_POLL_SECONDS = 3
|
||||||
|
|
||||||
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит
|
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит
|
||||||
# пульт мира, и только она отличает прожитый день от живого — зачем это
|
# пульт мира, и только она отличает прожитый день от живого — зачем это
|
||||||
@@ -41,6 +52,178 @@ CLICKHOUSE_CONNECTION = "clickhouse_default"
|
|||||||
WORLD_POSITION = "world_position"
|
WORLD_POSITION = "world_position"
|
||||||
|
|
||||||
|
|
||||||
|
def _kafka_readiness() -> tuple[bool, str]:
|
||||||
|
"""Получить подтвержденные и верхние смещения обоих разделов событий."""
|
||||||
|
from confluent_kafka import Consumer, ConsumerGroupTopicPartitions, TopicPartition
|
||||||
|
from confluent_kafka.admin import AdminClient
|
||||||
|
|
||||||
|
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
|
||||||
|
groups = admin.list_consumer_groups(request_timeout=10).result()
|
||||||
|
if groups.errors:
|
||||||
|
raise RuntimeError(f"Kafka не вернула список групп: {groups.errors}")
|
||||||
|
|
||||||
|
group_exists = any(group.group_id == HITS_GROUP for group in groups.valid)
|
||||||
|
committed: dict[int, int] = {}
|
||||||
|
if group_exists:
|
||||||
|
request = ConsumerGroupTopicPartitions(
|
||||||
|
HITS_GROUP,
|
||||||
|
[TopicPartition(HITS_TOPIC, partition) for partition in HITS_PARTITIONS],
|
||||||
|
)
|
||||||
|
result = admin.list_consumer_group_offsets([request], request_timeout=10)[
|
||||||
|
HITS_GROUP
|
||||||
|
].result()
|
||||||
|
for topic_partition in result.topic_partitions:
|
||||||
|
if topic_partition.error is None and topic_partition.offset >= 0:
|
||||||
|
committed[topic_partition.partition] = topic_partition.offset
|
||||||
|
|
||||||
|
high_watermarks: dict[int, int] = {}
|
||||||
|
watermark_errors: dict[int, str] = {}
|
||||||
|
consumer = Consumer(
|
||||||
|
{
|
||||||
|
"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS,
|
||||||
|
# Клиент требует group.id, но без подписки и poll() в группу не
|
||||||
|
# входит: здесь он только запрашивает верхние смещения топика.
|
||||||
|
"group.id": "etl-readiness",
|
||||||
|
"enable.auto.commit": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for partition in HITS_PARTITIONS:
|
||||||
|
try:
|
||||||
|
bounds = consumer.get_watermark_offsets(
|
||||||
|
TopicPartition(HITS_TOPIC, partition), timeout=10
|
||||||
|
)
|
||||||
|
if bounds is not None and bounds[1] >= 0:
|
||||||
|
high_watermarks[partition] = bounds[1]
|
||||||
|
except Exception as error:
|
||||||
|
watermark_errors[partition] = str(error)
|
||||||
|
finally:
|
||||||
|
consumer.close()
|
||||||
|
|
||||||
|
all_known = group_exists
|
||||||
|
total_lag = 0
|
||||||
|
parts: list[str] = []
|
||||||
|
for partition in HITS_PARTITIONS:
|
||||||
|
offset = committed.get(partition)
|
||||||
|
high = high_watermarks.get(partition)
|
||||||
|
lag = high - offset if offset is not None and high is not None else None
|
||||||
|
if lag is None or lag < 0:
|
||||||
|
all_known = False
|
||||||
|
else:
|
||||||
|
total_lag += lag
|
||||||
|
logging.info(
|
||||||
|
"Kafka %s[%s]: подтверждено=%s, верхнее смещение=%s, отставание=%s",
|
||||||
|
HITS_TOPIC,
|
||||||
|
partition,
|
||||||
|
offset,
|
||||||
|
high,
|
||||||
|
lag,
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f"{HITS_TOPIC}[{partition}]: подтверждено={offset}, "
|
||||||
|
f"верхнее смещение={high}, отставание={lag}"
|
||||||
|
)
|
||||||
|
if partition in watermark_errors:
|
||||||
|
parts[-1] += (
|
||||||
|
f", ошибка чтения верхнего смещения={watermark_errors[partition]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not group_exists:
|
||||||
|
logging.info("группа потребителей %s отсутствует", HITS_GROUP)
|
||||||
|
lag_text = str(total_lag) if all_known else "неизвестно"
|
||||||
|
logging.info("суммарное отставание Kafka: %s", lag_text)
|
||||||
|
detail = "; ".join(parts)
|
||||||
|
if not group_exists:
|
||||||
|
detail = f"группа {HITS_GROUP} отсутствует; {detail}"
|
||||||
|
return all_known and total_lag == 0, detail
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_queue_readiness() -> tuple[bool, str]:
|
||||||
|
"""Получить число недоставленных файлов событий на каждой ноде."""
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
node,
|
||||||
|
sum(queued_files) AS files,
|
||||||
|
arrayStringConcat(
|
||||||
|
groupArrayIf(
|
||||||
|
last_exception,
|
||||||
|
queued_files > 0 AND notEmpty(last_exception)
|
||||||
|
),
|
||||||
|
'; '
|
||||||
|
) AS last_exception
|
||||||
|
FROM
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
hostName() AS node,
|
||||||
|
toUInt64(0) AS queued_files,
|
||||||
|
'' AS last_exception
|
||||||
|
FROM clusterAllReplicas('{CLICKHOUSE_CLUSTER}', system.one)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
hostName(),
|
||||||
|
data_files + broken_data_files AS queued_files,
|
||||||
|
last_exception
|
||||||
|
FROM clusterAllReplicas(
|
||||||
|
'{CLICKHOUSE_CLUSTER}', system.distribution_queue
|
||||||
|
)
|
||||||
|
WHERE database = 'ods' AND table = 'event_dist'
|
||||||
|
)
|
||||||
|
GROUP BY node
|
||||||
|
ORDER BY node
|
||||||
|
"""
|
||||||
|
rows = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION).get_records(query)
|
||||||
|
ready = True
|
||||||
|
nodes: list[str] = []
|
||||||
|
for node, files, last_exception in rows:
|
||||||
|
files = int(files)
|
||||||
|
ready = ready and files == 0
|
||||||
|
logging.info(
|
||||||
|
"очередь ods.event_dist на %s: файлов=%s, последняя ошибка=%s",
|
||||||
|
node,
|
||||||
|
files,
|
||||||
|
last_exception or "нет",
|
||||||
|
)
|
||||||
|
detail = f"{node}: файлов={files}"
|
||||||
|
if last_exception:
|
||||||
|
detail += f", последняя ошибка={last_exception}"
|
||||||
|
nodes.append(detail)
|
||||||
|
return ready, "; ".join(nodes)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_phase(
|
||||||
|
probe,
|
||||||
|
phase: str,
|
||||||
|
deadline: float,
|
||||||
|
) -> None:
|
||||||
|
"""Дождаться прохождения одного барьера до общего крайнего срока."""
|
||||||
|
last_observation = "первый опрос не выполнен"
|
||||||
|
last_error: str | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
ready, last_observation = probe()
|
||||||
|
last_error = None
|
||||||
|
except Exception as error:
|
||||||
|
ready = False
|
||||||
|
last_error = str(error)
|
||||||
|
logging.warning("%s: ошибка опроса: %s", phase, last_error)
|
||||||
|
if ready:
|
||||||
|
return
|
||||||
|
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining > 0:
|
||||||
|
time.sleep(min(READINESS_POLL_SECONDS, remaining))
|
||||||
|
|
||||||
|
detail = last_observation
|
||||||
|
if last_error:
|
||||||
|
detail += f"; ошибка последнего опроса: {last_error}"
|
||||||
|
raise AirflowException(
|
||||||
|
f"общий срок ожидания готовности в {READINESS_TIMEOUT_SECONDS} с "
|
||||||
|
f'истек на этапе "{phase}": {detail}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _world_position() -> int:
|
def _world_position() -> int:
|
||||||
"""Позиция мира числом, с внятным отказом на непригодном значении.
|
"""Позиция мира числом, с внятным отказом на непригодном значении.
|
||||||
|
|
||||||
@@ -110,6 +293,17 @@ def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
|
|||||||
def dds_transform():
|
def dds_transform():
|
||||||
"""Пересобрать сущности DDS по текущему состоянию источников."""
|
"""Пересобрать сущности DDS по текущему состоянию источников."""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def wait_for_source() -> None:
|
||||||
|
"""Дождаться чтения событий из Kafka и их доставки в ODS."""
|
||||||
|
deadline = time.monotonic() + READINESS_TIMEOUT_SECONDS
|
||||||
|
_wait_for_phase(_kafka_readiness, "Kafka", deadline)
|
||||||
|
_wait_for_phase(
|
||||||
|
_delivery_queue_readiness,
|
||||||
|
"очередь доставки ClickHouse",
|
||||||
|
deadline,
|
||||||
|
)
|
||||||
|
|
||||||
@task_group(group_id="order")
|
@task_group(group_id="order")
|
||||||
def order():
|
def order():
|
||||||
"""Заказ: дышащее окно плюс дни, которых в слое ещё нет."""
|
"""Заказ: дышащее окно плюс дни, которых в слое ещё нет."""
|
||||||
@@ -229,8 +423,10 @@ def dds_transform():
|
|||||||
|
|
||||||
rebuild >> replace
|
rebuild >> replace
|
||||||
|
|
||||||
|
source_ready = wait_for_source()
|
||||||
orders = order()
|
orders = order()
|
||||||
session()
|
sessions = session()
|
||||||
|
source_ready >> [orders, sessions]
|
||||||
|
|
||||||
identity_map = SQLExecuteQueryOperator(
|
identity_map = SQLExecuteQueryOperator(
|
||||||
task_id="identity_map",
|
task_id="identity_map",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Дневной конвейер: сначала DDS, затем DM.
|
||||||
|
|
||||||
|
Своих преобразований у дага нет. Ждущие триггеры связывают состояния:
|
||||||
|
если слой краснеет, конвейер не запускает следующий слой и краснеет сам.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
|
||||||
|
from airflow.sdk import dag
|
||||||
|
|
||||||
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="etl_pipeline",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=["etl"],
|
||||||
|
)
|
||||||
|
def etl_pipeline():
|
||||||
|
"""Собрать слои последовательно по текущему состоянию данных."""
|
||||||
|
dds = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_dds",
|
||||||
|
trigger_dag_id="dds_transform",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
dm = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_dm",
|
||||||
|
trigger_dag_id="dm_transform",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
dds >> dm
|
||||||
|
|
||||||
|
|
||||||
|
etl_pipeline()
|
||||||
+11
-1
@@ -12,6 +12,9 @@
|
|||||||
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
|
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
|
||||||
`orders_ingest`.
|
`orders_ingest`.
|
||||||
|
|
||||||
|
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
|
||||||
|
только разгоняет источники: слои после нее менти догоняет ручным запуском.
|
||||||
|
|
||||||
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
|
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
|
||||||
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
|
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
|
||||||
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
||||||
@@ -209,7 +212,14 @@ def world_live_day():
|
|||||||
["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC],
|
["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC],
|
||||||
)
|
)
|
||||||
|
|
||||||
first_day >> played >> sent >> _ingest_orders() >> remember_played(first_day)
|
position = remember_played(first_day)
|
||||||
|
etl = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_etl_pipeline",
|
||||||
|
trigger_dag_id="etl_pipeline",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
first_day >> played >> sent >> _ingest_orders() >> position >> etl
|
||||||
|
|
||||||
|
|
||||||
@dag(
|
@dag(
|
||||||
|
|||||||
+11
-4
@@ -1,9 +1,10 @@
|
|||||||
"""Создание и пересоздание прикладного мира.
|
"""Создание и пересоздание прикладного мира.
|
||||||
|
|
||||||
Compose поднимает службы, а этот граф создаёт всё прикладное поверх них:
|
Compose поднимает службы, а этот граф создаёт всё прикладное поверх них:
|
||||||
топики, схему ClickHouse, данные стартовых дней и позицию модельного времени.
|
топики, схему ClickHouse, данные стартовых дней, позицию модельного времени и
|
||||||
`world_initialize` безопасен для обычного `make up`; `world_recreate` — явная
|
готовые слои. `world_initialize` безопасен для обычного `make up`;
|
||||||
операция обслуживания, которая сначала объявляет прежний мир недействительным.
|
`world_recreate` — явная операция обслуживания, которая сначала объявляет
|
||||||
|
прежний мир недействительным.
|
||||||
|
|
||||||
Контракт состояний и доводы — ADR 0013 и спека жизненного цикла мира.
|
Контракт состояний и доводы — ADR 0013 и спека жизненного цикла мира.
|
||||||
"""
|
"""
|
||||||
@@ -262,9 +263,15 @@ def world_initialize():
|
|||||||
poke_interval=10,
|
poke_interval=10,
|
||||||
)
|
)
|
||||||
position = remember_starting_position()
|
position = remember_starting_position()
|
||||||
|
etl = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_etl_pipeline",
|
||||||
|
trigger_dag_id="etl_pipeline",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
|
||||||
route >> [ready, topics]
|
route >> [ready, topics]
|
||||||
chain(topics, *ddl_tasks, image, events, arrived, snapshots, orders, position)
|
chain(topics, *ddl_tasks, image, events, arrived, snapshots, orders, position, etl)
|
||||||
|
|
||||||
|
|
||||||
@dag(
|
@dag(
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ Kafka, DDL прикладных баз ClickHouse, стартовыми данн
|
|||||||
|
|
||||||
`world_recreate` ставит `-1` до первого удаления и `0` только после успешной
|
`world_recreate` ставит `-1` до первого удаления и `0` только после успешной
|
||||||
очистки. `world_initialize` принимает отсутствие позиции или `0`, а значение
|
очистки. `world_initialize` принимает отсутствие позиции или `0`, а значение
|
||||||
`8` и больше считает готовым миром. Позиция становится `8` последней задачей,
|
`8` и больше считает готовым миром. После генерации стартовых дней и успешного
|
||||||
после генерации стартовых дней и успешного приёма обоих источников. Обычные
|
приема обоих источников позиция становится `8`; затем инициализация последним
|
||||||
даги движения мира работают только с готовой позицией.
|
шагом ждет дневной конвейер `etl_pipeline`. При отказе преобразований мир
|
||||||
|
остается готовым на позиции `8`, а конвейер после исправления запускают рукой.
|
||||||
|
Обычные даги движения мира работают только с готовой позицией.
|
||||||
|
|
||||||
## Почему
|
## Почему
|
||||||
|
|
||||||
@@ -39,9 +41,10 @@ Kafka, DDL прикладных баз ClickHouse, стартовыми данн
|
|||||||
Kafka сообщения и офсеты прежнего мира.
|
Kafka сообщения и офсеты прежнего мира.
|
||||||
|
|
||||||
Airflow уже ведёт последующие дни и приём заказов. Отдав ему начало той же оси,
|
Airflow уже ведёт последующие дни и приём заказов. Отдав ему начало той же оси,
|
||||||
получаем один наблюдаемый процесс и одну точку успеха: зелёный
|
получаем один наблюдаемый процесс и одну точку успеха: зеленая ветка создания в
|
||||||
`world_initialize` означает, что схема создана, стартовый мир принят и его
|
`world_initialize` подтверждает, что схема создана, стартовый мир принят, его
|
||||||
следующий день известен.
|
следующий день известен, а слои DDS и DM собраны. Короткая ветка готового мира
|
||||||
|
по-прежнему подтверждает позицию и ничего не меняет.
|
||||||
|
|
||||||
## Что это решение уточняет
|
## Что это решение уточняет
|
||||||
|
|
||||||
|
|||||||
@@ -125,9 +125,9 @@ Airflow. Партиция — день данных; прогон звена DQ
|
|||||||
`TRUNCATE+INSERT` выполняется той же механикой замены, что и у витрин, —
|
`TRUNCATE+INSERT` выполняется той же механикой замены, что и у витрин, —
|
||||||
ничего отдельного учить не надо.
|
ничего отдельного учить не надо.
|
||||||
|
|
||||||
## Звено DQ конвейера
|
## Будущее звено DQ конвейера
|
||||||
|
|
||||||
Встаёт в `etl_pipeline` после DM ([конвейер](etl.md)) и проверяет только
|
Встанет в `etl_pipeline` после DM ([конвейер](etl.md)) и проверит только
|
||||||
объекты стенда:
|
объекты стенда:
|
||||||
|
|
||||||
- счётная сверка сессий с эталоном `VisitID` — кука × день, эталон читается
|
- счётная сверка сессий с эталоном `VisitID` — кука × день, эталон читается
|
||||||
|
|||||||
+29
-12
@@ -13,8 +13,8 @@
|
|||||||
|
|
||||||
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы
|
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы
|
||||||
у него нет: он дёргает слойные даги последовательно и ждёт каждый —
|
у него нет: он дёргает слойные даги последовательно и ждёт каждый —
|
||||||
`dds_transform`, затем `dm_transform`. Контур DQ этапа 4 встаёт его же
|
`dds_transform`, затем `dm_transform`. Будущее звено DQ встанет после DM.
|
||||||
звеном после DM; состав звена — в [доке слоя DM](dm.md).
|
Его состав описан в [доке слоя DM](dm.md).
|
||||||
|
|
||||||
Слойные даги устроены по образцу `orders_ingest`: без расписания, один
|
Слойные даги устроены по образцу `orders_ingest`: без расписания, один
|
||||||
активный прогон, запускаются триггером конвейера или рукой при разборе
|
активный прогон, запускаются триггером конвейера или рукой при разборе
|
||||||
@@ -22,6 +22,14 @@
|
|||||||
|
|
||||||
## Три пути запуска
|
## Три пути запуска
|
||||||
|
|
||||||
|
```text
|
||||||
|
world_initialize ─┐
|
||||||
|
world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transform
|
||||||
|
запуск рукой ─────┘
|
||||||
|
|
||||||
|
world_next_day ─────> источники без ETL
|
||||||
|
```
|
||||||
|
|
||||||
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
|
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
|
||||||
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
|
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
|
||||||
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
|
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
|
||||||
@@ -73,17 +81,26 @@ ODS и ещё не собранный в DDS, затем при необходи
|
|||||||
|
|
||||||
## Готовность источника
|
## Готовность источника
|
||||||
|
|
||||||
Первым шагом `dds_transform` обязан дождаться, пока хвост топика `hits`
|
Первый шаг `dds_transform` проходит два барьера за общий срок в 300 секунд.
|
||||||
дожёван: отставание его потребителей нулевое (`system.kafka_consumers`).
|
Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы
|
||||||
Зелёный приём заказов о событиях не говорит ничего — топики разные и
|
`clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
|
||||||
потребители разные. В живом режиме зазор ничтожен, но в пакетном генератор
|
раздела и все смещения должны существовать. Суммарное отставание должно стать
|
||||||
выстреливает дни в топик быстрее, чем чтец их прожёвывает, и сессии собираются
|
нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
|
||||||
по недожёванному дню. DQ-сверка такой день подсветит, но починить его до
|
|
||||||
этапа 5 нечем: профилактика дешевле ремонта, которого ещё нет.
|
|
||||||
|
|
||||||
Шага этого в даге ещё нет — он приезжает отдельным тикетом (#156), и до тех
|
Только после этого шаг проверяет обе ноды ClickHouse через
|
||||||
пор дыра открыта: сессии считают день прожитым по позиции мира, а хвост потока
|
`clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не
|
||||||
не стережёт никто. На приёмке отставание смотрят руками.
|
должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и
|
||||||
|
`last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят
|
||||||
|
один крайний срок. После Kafka срок не отсчитывается заново.
|
||||||
|
|
||||||
|
Зелёный приём заказов о событиях не говорит ничего — топики разные и
|
||||||
|
потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог
|
||||||
|
подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард.
|
||||||
|
Два барьера закрывают обе части пути до того, как сессии выберут свой объем.
|
||||||
|
|
||||||
|
Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по
|
||||||
|
ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что
|
||||||
|
оператор опрашивает точный созданный им прогон и краснеет вслед за потомком.
|
||||||
|
|
||||||
## Идемпотентность
|
## Идемпотентность
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,33 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
|||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
|
**Приемка #156 27 августа 2026 года.** `make rebuild-storage` занял 7 минут.
|
||||||
|
На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался
|
||||||
|
`etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли
|
||||||
|
`make smoke` 21/21 и `make check-clickhouse` 10/10.
|
||||||
|
|
||||||
|
Два прогона `world_next_day` не изменили слои; один ручной `etl_pipeline` без
|
||||||
|
параметров догнал оба дня. Полный прогон `world_live_day` занял 27 м 2 с и
|
||||||
|
завершился только после успешных DDS и DM.
|
||||||
|
|
||||||
|
Временная синтаксическая поломка `sql/dm/daily_traffic_rebuild.sql` дала
|
||||||
|
красную цепочку: DDS завершился успешно, а `daily_traffic`, DM, `etl_pipeline`
|
||||||
|
и `world_live_day` покраснели. После возврата исправного SQL ручной
|
||||||
|
`etl_pipeline` без параметров завершился успешно на позиции `12` и восстановил
|
||||||
|
слои.
|
||||||
|
|
||||||
|
Для проверки пакетного барьера `stg.hits_raw_mv` временно отсоединили командой
|
||||||
|
`DETACH` на обеих нодах. После `world_next_day` отставание Kafka составило
|
||||||
|
46 367 (19 128 + 27 239). Во время ожидания матвью вернули командой `ATTACH`.
|
||||||
|
Журнал готовности записал повторные опросы с отставанием 46 367, затем 27 239
|
||||||
|
и 0; после этого очередь `ods.event_dist` содержала 0 файлов на обеих нодах,
|
||||||
|
и ETL завершился успешно. Проверка `EXISTS` снова дала `1` на обеих нодах,
|
||||||
|
позиция равна `13`.
|
||||||
|
|
||||||
|
После пересборки `make check-services` дважды дал 5/1: только импорт Superset
|
||||||
|
не уложился в существующий тайм-аут 30 с. Диагностический пробник импорта с
|
||||||
|
ожиданием 90 с прошел 3/0. Финальный штатный повтор прошел 7/7 за 129 с.
|
||||||
|
|
||||||
**Перезамер 21 августа 2026 года при исполнении #124.** Образ Airflow собран с
|
**Перезамер 21 августа 2026 года при исполнении #124.** Образ Airflow собран с
|
||||||
`apache-airflow-providers-clickhousedb==1.0.0`,
|
`apache-airflow-providers-clickhousedb==1.0.0`,
|
||||||
`apache-airflow-providers-common-sql==2.0.1` и
|
`apache-airflow-providers-common-sql==2.0.1` и
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ DDL и двумя одноразовыми службами заливал во
|
|||||||
|
|
||||||
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
|
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
|
||||||
прикладная схема, данные мира и позиция модельного времени, — и Compose,
|
прикладная схема, данные мира и позиция модельного времени, — и Compose,
|
||||||
который держит только инфраструктуру. Зелёный `world_initialize` значит ровно
|
который держит только инфраструктуру. Зеленая ветка создания в
|
||||||
одно: схема создана, оба источника приняты, следующий день известен.
|
`world_initialize` подтверждает: схема создана, оба источника приняты,
|
||||||
|
следующий день известен, слои DDS и DM собраны.
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
@@ -106,11 +107,16 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
|
|||||||
приехало» условием не считается.
|
приехало» условием не считается.
|
||||||
7. Отправляет слепки заказов вторым прогоном образа — со сдвигом, дни 0…6
|
7. Отправляет слепки заказов вторым прогоном образа — со сдвигом, дни 0…6
|
||||||
([заказы в стартовом мире](../architecture/orders/start-world.md)).
|
([заказы в стартовом мире](../architecture/orders/start-world.md)).
|
||||||
8. Дёргает `orders_ingest` ждущим триггером и зеленеет только вслед за ним.
|
8. Дергает `orders_ingest` ждущим триггером и продолжает только после его
|
||||||
9. Последней успешной задачей ставит позицию в `WORLD_STARTING_DAYS`.
|
успеха.
|
||||||
|
9. Ставит позицию в `WORLD_STARTING_DAYS`: источники приняты, мир уже создан.
|
||||||
|
10. Последним шагом дергает `etl_pipeline` ждущим триггером. Конвейер
|
||||||
|
последовательно ждет `dds_transform` и `dm_transform`; отказ слоя красит
|
||||||
|
инициализацию.
|
||||||
|
|
||||||
Порядок задач связывает проверки: каждый шаг подтверждён предыдущим, а красная
|
Порядок задач связывает проверки: каждый шаг подтверждён предыдущим, а красная
|
||||||
задача называет место остановки.
|
задача называет место остановки. Ветка "мир уже существует" конвейер не
|
||||||
|
запускает: обычный повтор `make up` по-прежнему ничего не меняет.
|
||||||
|
|
||||||
### `world_recreate` — снести мир и создать заново
|
### `world_recreate` — снести мир и создать заново
|
||||||
|
|
||||||
@@ -163,9 +169,15 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
|
|||||||
недействителен и пересборку надо повторить. Стенд обязан назвать незавершённую
|
недействителен и пересборку надо повторить. Стенд обязан назвать незавершённую
|
||||||
очистку вслух и не продолжать работу молча.
|
очистку вслух и не продолжать работу молча.
|
||||||
|
|
||||||
**Очистка прошла, потомок не доработал.** Позиция остаётся `0`. Инициализация
|
**Очистка прошла, создание остановилось до установки позиции.** Позиция
|
||||||
детерминирована, поэтому следующий `make up` просто пробует её заново — с
|
остаётся `0`. Инициализация детерминирована, поэтому следующий `make up` просто
|
||||||
того же места и с тем же результатом.
|
пробует её заново — с того же места и с тем же результатом.
|
||||||
|
|
||||||
|
**ETL упал после установки позиции.** Мир и оба источника уже готовы, поэтому
|
||||||
|
позиция остается в `WORLD_STARTING_DAYS`. После исправления запускают рукой
|
||||||
|
`etl_pipeline`: он сам выводит объем из состояния данных. Повторный
|
||||||
|
`world_initialize` здесь не поможет: при готовой позиции инициализация уйдет в
|
||||||
|
короткую ветку без изменений.
|
||||||
|
|
||||||
**Позицию в `WORLD_STARTING_DAYS` ставит только успех обоих источников.**
|
**Позицию в `WORLD_STARTING_DAYS` ставит только успех обоих источников.**
|
||||||
События доехали, слепки приняты, `orders_ingest` зелёный — тогда и только тогда
|
События доехали, слепки приняты, `orders_ingest` зелёный — тогда и только тогда
|
||||||
@@ -181,10 +193,11 @@ ODS истинно сразу — этим оно и ограничено, об
|
|||||||
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
|
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
|
||||||
делает себе сам и тут же видит.
|
делает себе сам и тут же видит.
|
||||||
|
|
||||||
**Слоты Airflow.** Их четыре, и вложенные ожидания в них укладываются, пока
|
**Слоты Airflow.** Их четыре. При пересборке `world_recreate` ждет
|
||||||
пересборка не наложилась на движение мира: `world_recreate` ждёт
|
`world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
|
||||||
`world_initialize`, тот ждёт `orders_ingest` — три слота из четырёх. Одновременно
|
режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
|
||||||
работающий выключатель добавляет свои и загоняет стенд в тупик ожиданий.
|
и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
|
||||||
|
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик.
|
||||||
Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live`
|
Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live`
|
||||||
на паузу и даёт работникам доиграть. Предусловие остаётся в документации;
|
на паузу и даёт работникам доиграть. Предусловие остаётся в документации;
|
||||||
механизм блокировок стоил бы дороже этой строки.
|
механизм блокировок стоил бы дороже этой строки.
|
||||||
@@ -214,8 +227,8 @@ ODS истинно сразу — этим оно и ограничено, об
|
|||||||
мир уже есть.
|
мир уже есть.
|
||||||
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит
|
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит
|
||||||
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы
|
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы
|
||||||
заново, офсеты групп идут с нуля. Цена прогона называется числом и сверяется
|
заново, офсеты групп идут с нуля, DDS и DM собраны. Время прогона измеряется
|
||||||
с прежними 2 м 25 с.
|
заново.
|
||||||
- **Красные пути проверяются своими отказами.** Сторож работника при
|
- **Красные пути проверяются своими отказами.** Сторож работника при
|
||||||
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
|
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
|
||||||
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
|
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
-- ранние дни из ods.event не исчезают: срок жизни назначен сырью и таблицам
|
-- ранние дни из ods.event не исчезают: срок жизни назначен сырью и таблицам
|
||||||
-- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql).
|
-- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql).
|
||||||
--
|
--
|
||||||
-- За «доехал» позиция не отвечает — только за «прожит». Дождаться хвоста
|
-- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма
|
||||||
-- потока — работа отдельного шага готовности источника, и в даге его пока нет
|
-- первый шаг дага дожидается хвоста потока и очереди доставки: иначе
|
||||||
-- (#156): до него недожёванный хвост даёт замёрзший недособранный день
|
-- недожеванный хвост заморозил бы недособранный день
|
||||||
-- (docs/architecture/etl.md, «Готовность источника»).
|
-- (docs/architecture/etl.md, «Готовность источника»).
|
||||||
WITH
|
WITH
|
||||||
(SELECT min(EventDate) FROM ods.event_dist) AS first_source_day,
|
(SELECT min(EventDate) FROM ods.event_dist) AS first_source_day,
|
||||||
|
|||||||
Reference in new issue
Block a user