docs(ui): уточнены подсказки и сообщения учебного стенда
Зачем: до запуска менти должен понимать действие параметров, а после ошибки проверки — где искать причину. Что: уточнены тексты Airflow, самопроверки, DQ и run-dag.sh, подписи Superset и Grafana. Логика, запросы, метрики и идентификаторы сохранены. Проверка: make lint, git diff --check, bash -n; разбор YAML/JSON, сравнение структуры Python и конфигурации, искусственные примеры ошибок. Airflow и Grafana проверены через локальные API, Superset — по YAML. Визуальная проверка интерфейсов не выполнена: браузер недоступен.
This commit is contained in:
1 parent
431fed72ad
commit
d15f4fe4d8
12 files changed
+145
-71
No files matched your search
+23
-8
@@ -74,7 +74,11 @@ def _publish_summary(
|
||||
) -> list[str]:
|
||||
"""Записать дневную сводку и вернуть малый срез готовой диагностики."""
|
||||
if not summaries:
|
||||
raise AirflowException(f"{check_name}: область проверки пуста")
|
||||
raise AirflowException(
|
||||
f"{check_name}: запрос не вернул ни одного дня для проверки; "
|
||||
"проверьте наличие данных в источниках сверки. Состав проверок "
|
||||
"описан в docs/architecture/dm.md"
|
||||
)
|
||||
|
||||
diagnostics: list[str] = []
|
||||
context = get_current_context()
|
||||
@@ -148,7 +152,10 @@ def build_classes_vs_inventory() -> list[str]:
|
||||
for mismatch_class, count in day["orders"].items()
|
||||
}
|
||||
if not expected:
|
||||
raise AirflowException("classes_vs_inventory: в паспорте нет закрытых дней")
|
||||
raise AirflowException(
|
||||
"classes_vs_inventory: в data/world-inventory.json нет счетчиков "
|
||||
"заказов для закрытых дней; сообщите наставнику"
|
||||
)
|
||||
|
||||
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
|
||||
# GROUP BY закономерно не возвращает группу из нуля строк.
|
||||
@@ -179,9 +186,9 @@ def build_classes_vs_inventory() -> list[str]:
|
||||
failed_rows += 1
|
||||
actual_text = actual_count if actual_count is not None else "нет ключа"
|
||||
details.append(
|
||||
f"{day} key={mismatch_class}: "
|
||||
f"эталон={reference_count}, "
|
||||
f"объект={actual_text}"
|
||||
f"{day} mismatch_class={mismatch_class}: "
|
||||
f"заказов в паспорте={reference_count}, "
|
||||
f"в dm.purchase_vs_orders_v={actual_text}"
|
||||
)
|
||||
summaries.append((data_date, reference_rows, actual_rows, failed_rows, details))
|
||||
return _publish_summary("classes_vs_inventory", summaries)
|
||||
@@ -194,7 +201,10 @@ def publication_scope() -> list[dict[str, dict[str, str]]]:
|
||||
"SELECT DISTINCT data_date FROM dm.dq_summary_stage_dist ORDER BY data_date"
|
||||
)
|
||||
if not rows:
|
||||
raise AirflowException("донор dm.dq_summary пуст: публиковать нечего")
|
||||
raise AirflowException(
|
||||
"dm.dq_summary_stage_dist пуста: нет результатов для записи "
|
||||
"в сводку DQ; проверьте журналы задач build_* этого запуска"
|
||||
)
|
||||
return [{"params": {"day": data_date.isoformat()}} for (data_date,) in rows]
|
||||
|
||||
|
||||
@@ -239,10 +249,15 @@ def assert_check(check_name: str, diagnostics: list[str]) -> None:
|
||||
logging.error("%s: %s", check_name, detail)
|
||||
summary = "; ".join(
|
||||
f"{data_date}: эталонных ключей {reference_rows}, "
|
||||
f"фактических {actual_rows}, нарушено {failed_rows}"
|
||||
f"ключей в проверяемом объекте {actual_rows}, "
|
||||
f"отсутствуют или различаются {failed_rows}"
|
||||
for data_date, reference_rows, actual_rows, failed_rows in failed_days
|
||||
)
|
||||
raise AirflowException(f"{check_name}: {summary}")
|
||||
raise AirflowException(
|
||||
f"{check_name}: {summary}. Примеры расхождений — в журнале этой задачи; "
|
||||
"итоги по дням — в dm.dq_summary_v. Источники каждой сверки описаны "
|
||||
"в docs/architecture/dm.md"
|
||||
)
|
||||
|
||||
|
||||
@dag(
|
||||
|
||||
@@ -45,7 +45,10 @@ def check_mart(
|
||||
"""Сверить одну домашнюю витрину с независимым ответом из источника."""
|
||||
hook = _clickhouse_hook()
|
||||
if not _mart_exists(hook, mart):
|
||||
raise AirflowException(f"витрина {mart} не найдена; выполните ТЗ {lab_path}")
|
||||
raise AirflowException(
|
||||
f"витрина {mart} не найдена на узле подключения clickhouse_default; "
|
||||
f"проверьте имя и место создания витрины по заданию {lab_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
query = (SQL_ROOT / sql_name).read_text(encoding="utf-8")
|
||||
@@ -72,7 +75,8 @@ def check_mart(
|
||||
except Exception as error:
|
||||
raise AirflowException(
|
||||
f"не удалось проверить витрину {mart}: {error}; "
|
||||
f"сверьте договор колонок и проверку в ТЗ {lab_path}"
|
||||
f"сверьте имена и типы колонок с заданием {lab_path}. "
|
||||
"Подробности ошибки — в журнале этой задачи"
|
||||
) from error
|
||||
|
||||
failed_rows = sum(summary[3] for summary in summaries)
|
||||
@@ -93,7 +97,8 @@ def check_mart(
|
||||
compared_sides = diagnostics[0]
|
||||
raise AirflowException(
|
||||
f"витрина {mart} расходится с источником: {days}. "
|
||||
f"Пример сравнения: {compared_sides}. Сверьтесь с ТЗ {lab_path}"
|
||||
f"Пример сравнения: {compared_sides}. Другие примеры — в журнале этой "
|
||||
f"задачи. Проверьте расчет для указанных ключей по заданию {lab_path}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+29
-27
@@ -146,25 +146,25 @@ def _ingest_orders() -> TriggerDagRunOperator:
|
||||
type="integer",
|
||||
minimum=1,
|
||||
maximum=7,
|
||||
title="Сколько дней прожить",
|
||||
title="Число модельных дней для генерации",
|
||||
# description_md рендерится подсказкой прямо в форме запуска
|
||||
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
|
||||
description_md=(
|
||||
"Форма задает только число дней. Номер первого несыгранного "
|
||||
"дня шаговый даг берёт из переменной `world_position` (Admin → "
|
||||
"Variables). Поля Logical date и Run ID нужны самому Airflow "
|
||||
"и на выбор дня не влияют."
|
||||
"Создать данные за следующие 1–7 модельных дней; по умолчанию "
|
||||
"за один день. События отправляются без пауз. Номер первого дня "
|
||||
"берется из `world_position` (Admin → Variables). Поля Logical "
|
||||
"date и Run ID не меняют выбор дня. После завершения запустите "
|
||||
"`etl_pipeline`, чтобы обновить DDS и витрины DM."
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
def world_next_day():
|
||||
"""Прожить следующие дни пачкой, без пауз.
|
||||
"""Создать данные за следующие модельные дни без пауз между событиями.
|
||||
|
||||
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
|
||||
нажимом, а не десятью: сколько дней играть — параметр запуска. Первый
|
||||
день шаговый даг берёт из `world_position`. Логическая дата прогона на выбор
|
||||
дня не влияет.
|
||||
Параметр `days` задает число дней: от 1 до 7, по умолчанию 1. Первый день
|
||||
берется из `world_position`. После загрузки событий и слепков заказов
|
||||
запустите `etl_pipeline` вручную, чтобы обновить DDS и витрины DM.
|
||||
"""
|
||||
|
||||
@task
|
||||
@@ -174,7 +174,10 @@ def world_next_day():
|
||||
position = first_day + days
|
||||
Variable.set(WORLD_POSITION, str(position))
|
||||
logging.info(
|
||||
"дни %s–%s сыграны; новая позиция — %s", first_day, position - 1, position
|
||||
"данные за модельные дни %s–%s отправлены; world_position=%s",
|
||||
first_day,
|
||||
position - 1,
|
||||
position,
|
||||
)
|
||||
|
||||
first_day = first_unplayed_day()
|
||||
@@ -210,20 +213,23 @@ def world_next_day():
|
||||
tags=TAGS,
|
||||
)
|
||||
def world_live_day():
|
||||
"""Прожить следующий день в темпе модельного времени.
|
||||
"""Постепенно отправить события за следующий модельный день.
|
||||
|
||||
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
|
||||
реальные минуты, и суточная волна разворачивается на глазах. Запускается
|
||||
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
|
||||
Следующий день шаговый даг берёт из `world_position`. Логическая дата прогона
|
||||
на выбор дня не влияет.
|
||||
При ускорении ×60 отправка занимает около 24 реальных минут. Номер дня
|
||||
берется из `world_position`; Logical date его не меняет. Затем даг
|
||||
отправляет слепок заказов и ждет его загрузки и завершения `etl_pipeline`.
|
||||
Для автоматического запуска следующих дней снимите с паузы `world_live`.
|
||||
"""
|
||||
|
||||
@task
|
||||
def remember_played(first_day: int) -> None:
|
||||
"""Позиция ставится по сыгранному дню и только по успеху."""
|
||||
Variable.set(WORLD_POSITION, str(first_day + 1))
|
||||
logging.info("сыгран день %s; новая позиция — %s", first_day, first_day + 1)
|
||||
logging.info(
|
||||
"данные за модельный день %s отправлены; world_position=%s",
|
||||
first_day,
|
||||
first_day + 1,
|
||||
)
|
||||
|
||||
first_day = first_unplayed_day()
|
||||
played = _generator("play_day", ["live", "--day", PLAYED_DAY])
|
||||
@@ -254,16 +260,12 @@ def world_live_day():
|
||||
tags=TAGS,
|
||||
)
|
||||
def world_live():
|
||||
"""Выключатель: пока включён, мир живёт день за днём.
|
||||
"""Автоматически запускать модельные дни, пока даг снят с паузы.
|
||||
|
||||
Своей работы у выключателя нет — он дёргает `world_live_day` и ждёт конца.
|
||||
Ожидание тут несущая конструкция, а не вежливость: без него тик шёл бы
|
||||
независимо от хода дня, лишние прогоны скопились бы очередью, и мир потом
|
||||
промчался бы по ней без всякого темпа.
|
||||
|
||||
Ждём триггером, а не сенсором: оператор опрашивает тот прогон, который сам
|
||||
и создал, и ссылка на дочерний прогон видна прямо отсюда. Упал день —
|
||||
краснеет и выключатель.
|
||||
Запускает `world_live_day` и ждет его завершения. Пауза останавливает
|
||||
запуск следующих дней; уже запущенный день завершится. Если он завершился
|
||||
с ошибкой, этот даг тоже завершится с ошибкой: откройте дочерний запуск
|
||||
и журнал неуспешной задачи.
|
||||
"""
|
||||
TriggerDagRunOperator(
|
||||
task_id="trigger_live_day",
|
||||
|
||||
Reference in new issue
Block a user