fix(dq): устранено переполнение памяти проверок

- Зачем:
  - параллельный DQ выгружал деловые ключи в Python и исчерпывал 640 МиБ планировщика.
- Что:
  - четыре сверки агрегируют дневные итоги и ограниченную диагностику в ClickHouse.
  - коробка планировщика поднята до 1,5 ГиБ, локальные пределы задач удалены.
  - измерения и граница между SQL и Python записаны в документации.
- Проверка:
  - make lint, make config-test, make smoke, make check-clickhouse, make check-services.
  - полный etl_pipeline, красный и восстановительный прогоны dq_check.
This commit is contained in:
ddadmin committed 2026-08-27 11:25:19 -04:00
1 parent 26b2e5fea1
commit 54686651df
10 files changed
+295 -190

No files matched your search

+4 -1
View File
@@ -310,7 +310,10 @@ services:
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro - ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
- airflow_logs:/opt/airflow/logs - airflow_logs:/opt/airflow/logs
- airflow_auth:/opt/airflow/auth - airflow_auth:/opt/airflow/auth
mem_limit: 640m # LocalExecutor запускает здесь до четырех процессов задач одновременно.
# 640 МиБ дали OOM, а 1 ГиБ - давление на memory.max при штатном dq_check;
# 1,5 ГиБ оставляет запас при AIRFLOW__CORE__PARALLELISM=4.
mem_limit: 1536m
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"] test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"]
interval: 10s interval: 10s
-11
View File
@@ -279,11 +279,6 @@ def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
# Два прогона разом собирали бы один и тот же двойник: второй затёр бы # Два прогона разом собирали бы один и тот же двойник: второй затёр бы
# чужую сборку между вставкой и заменой партиций. # чужую сборку между вставкой и заменой партиций.
max_active_runs=1, max_active_runs=1,
# Потолок на задачи в прогоне. Размноженная замена норовит выстрелить
# всеми днями сразу, а каждая задача — свой процесс исполнителя, и
# планировщику отведено 640 МиБ (compose.yaml). Замерено: тринадцать дней
# двух сущностей без потолка кончаются SIGKILL от контрольной группы.
max_active_tasks=3,
template_searchpath=str(SQL_ROOT), template_searchpath=str(SQL_ROOT),
tags=["dds"], tags=["dds"],
) )
@@ -355,10 +350,6 @@ def dds_transform():
conn_id=CLICKHOUSE_CONNECTION, conn_id=CLICKHOUSE_CONNECTION,
sql="dds/order_replace.sql", sql="dds/order_replace.sql",
do_xcom_push=False, do_xcom_push=False,
# Замены не зависят друг от друга, но каждая — отдельный процесс
# LocalExecutor. Три разом получили SIGKILL при лимите scheduler
# 640 МиБ; последовательность не меняет деловой результат.
max_active_tis_per_dag=1,
).expand_kwargs(replacements(days)) ).expand_kwargs(replacements(days))
rebuild >> replace rebuild >> replace
@@ -420,8 +411,6 @@ def dds_transform():
conn_id=CLICKHOUSE_CONNECTION, conn_id=CLICKHOUSE_CONNECTION,
sql="dds/session_replace.sql", sql="dds/session_replace.sql",
do_xcom_push=False, do_xcom_push=False,
# Причина единицы разобрана у order.replace выше.
max_active_tis_per_dag=1,
).expand_kwargs(replacements(days)) ).expand_kwargs(replacements(days))
rebuild >> replace rebuild >> replace
-11
View File
@@ -76,10 +76,6 @@ def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
start_date=START_DATE, start_date=START_DATE,
is_paused_upon_creation=False, is_paused_upon_creation=False,
max_active_runs=1, max_active_runs=1,
# Каждая задача LocalExecutor — отдельный процесс. После появления
# dq_check три одновременные сборки DM получили SIGKILL при лимите
# scheduler 640 МиБ; две оставляют запас и сохраняют параллельность.
max_active_tasks=2,
template_searchpath=str(SQL_ROOT), template_searchpath=str(SQL_ROOT),
tags=["dm"], tags=["dm"],
) )
@@ -137,11 +133,6 @@ def dm_transform():
conn_id=CLICKHOUSE_CONNECTION, conn_id=CLICKHOUSE_CONNECTION,
sql="dm/revenue_daily_replace.sql", sql="dm/revenue_daily_replace.sql",
do_xcom_push=False, do_xcom_push=False,
# Airflow 3 применяет этот лимит и к размноженным экземплярам
# (сверено через Context7). Каждая замена - отдельный процесс
# LocalExecutor; три разом исчерпали память контейнера scheduler,
# а самим дням параллельность ничего не дает.
max_active_tis_per_dag=1,
).expand_kwargs(replacements(days)) ).expand_kwargs(replacements(days))
rebuild >> replace rebuild >> replace
@@ -199,8 +190,6 @@ def dm_transform():
conn_id=CLICKHOUSE_CONNECTION, conn_id=CLICKHOUSE_CONNECTION,
sql="dm/purchase_vs_orders_replace.sql", sql="dm/purchase_vs_orders_replace.sql",
do_xcom_push=False, do_xcom_push=False,
# Причина единицы разобрана у revenue_daily.replace выше.
max_active_tis_per_dag=1,
).expand_kwargs(replacements(days)) ).expand_kwargs(replacements(days))
rebuild >> replace rebuild >> replace
+72 -73
View File
@@ -11,7 +11,6 @@ from __future__ import annotations
import datetime import datetime
import json import json
import logging import logging
from collections import defaultdict
from pathlib import Path from pathlib import Path
from airflow.exceptions import AirflowException from airflow.exceptions import AirflowException
@@ -69,55 +68,40 @@ def _world_position() -> int:
return position return position
def _write_summary( def _publish_summary(
check_name: str, check_name: str,
comparisons: list[tuple[object, ...]], summaries: list[tuple[object, ...]],
) -> list[str]: ) -> list[str]:
"""Свернуть строки деловых ключей в дневную сводку и малый срез ошибок.""" """Записать дневную сводку и вернуть малый срез готовой диагностики."""
if not comparisons: if not summaries:
raise AirflowException(f"{check_name}: область проверки пуста") raise AirflowException(f"{check_name}: область проверки пуста")
totals: dict[datetime.date, list[int]] = defaultdict(lambda: [0, 0, 0])
diagnostics: list[str] = [] diagnostics: list[str] = []
for (
data_date,
business_key,
reference_present,
actual_present,
reference_value,
actual_value,
failed,
) in comparisons:
counts = totals[data_date]
counts[0] += int(reference_present)
counts[1] += int(actual_present)
counts[2] += int(failed)
if failed and len(diagnostics) < DETAIL_LIMIT:
reference_text = reference_value if reference_present else "нет ключа"
actual_text = actual_value if actual_present else "нет ключа"
diagnostics.append(
f"{data_date} key={business_key}: "
f"эталон={reference_text}, объект={actual_text}"
)
context = get_current_context() context = get_current_context()
load_id = context["run_id"] load_id = context["run_id"]
load_ts = datetime.datetime.now(datetime.UTC) load_ts = datetime.datetime.now(datetime.UTC)
rows = [ rows = []
( for (
data_date, data_date,
check_name, reference_rows,
reference_rows, actual_rows,
actual_rows, failed_rows,
failed_rows, details,
"pass" if failed_rows == 0 else "fail", ) in summaries:
load_id, rows.append(
load_ts, (
data_date,
check_name,
reference_rows,
actual_rows,
failed_rows,
"pass" if failed_rows == 0 else "fail",
load_id,
load_ts,
)
) )
for data_date, (reference_rows, actual_rows, failed_rows) in sorted( diagnostics.extend(details[: DETAIL_LIMIT - len(diagnostics)])
totals.items()
)
]
_clickhouse_hook().bulk_insert_rows( _clickhouse_hook().bulk_insert_rows(
"dm.dq_summary_stage_dist", "dm.dq_summary_stage_dist",
rows, rows,
@@ -126,17 +110,31 @@ def _write_summary(
return diagnostics return diagnostics
def _build_sql_check(
check_name: str,
sql_name: str,
parameters: dict[str, object] | None = None,
) -> list[str]:
"""Выполнить SQL-сверку и записать ее дневные итоги в донор."""
query = (SQL_ROOT / "dq" / sql_name).read_text(encoding="utf-8")
summaries = _clickhouse_hook().get_records(query, parameters=parameters)
return _publish_summary(check_name, summaries)
@task @task
def build_sql_check(check_name: str, sql_name: str) -> list[str]: def build_sql_check(check_name: str, sql_name: str) -> list[str]:
"""Выполнить одну SQL-сверку и записать ее дневные итоги в донор.""" """Собрать проверку, которой не нужны координаты модельного мира."""
query = (SQL_ROOT / "dq" / sql_name).read_text(encoding="utf-8") return _build_sql_check(check_name, sql_name)
parameters = (
{"position": _world_position()}
if check_name == "sessions_vs_reference" @task
else None def build_sessions_vs_reference() -> list[str]:
"""Сверить сессии только в пределах прожитых дней мира."""
return _build_sql_check(
"sessions_vs_reference",
"sessions_vs_reference.sql",
parameters={"position": _world_position()},
) )
comparisons = _clickhouse_hook().get_records(query, parameters=parameters)
return _write_summary(check_name, comparisons)
@task @task
@@ -158,19 +156,29 @@ def build_classes_vs_inventory() -> list[str]:
for data_date, mismatch_class, count in _clickhouse_hook().get_records(query) for data_date, mismatch_class, count in _clickhouse_hook().get_records(query)
if (data_date, mismatch_class) in expected if (data_date, mismatch_class) in expected
} }
comparisons = [ summaries = []
( for data_date in sorted({day for day, _ in expected}):
data_date, details = []
mismatch_class, reference_rows = 0
1, actual_rows = 0
int((data_date, mismatch_class) in actual), failed_rows = 0
reference_count, for (day, mismatch_class), reference_count in sorted(expected.items()):
actual.get((data_date, mismatch_class)), if day != data_date:
reference_count != actual.get((data_date, mismatch_class)), continue
) actual_key = (day, mismatch_class)
for (data_date, mismatch_class), reference_count in sorted(expected.items()) actual_count = actual.get(actual_key)
] reference_rows += 1
return _write_summary("classes_vs_inventory", comparisons) actual_rows += int(actual_key in actual)
if reference_count != actual_count:
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}"
)
summaries.append((data_date, reference_rows, actual_rows, failed_rows, details))
return _publish_summary("classes_vs_inventory", summaries)
@task @task
@@ -186,7 +194,7 @@ def publication_scope() -> list[dict[str, dict[str, str]]]:
@task @task
def drop_stale_partitions() -> None: def drop_stale_partitions() -> None:
"""Убрать дни, которые после полного прогона не покрывает ни одна сторона.""" """Убрать дни, исчезнувшие после починки с обеих сторон сверки."""
hook = _clickhouse_hook() hook = _clickhouse_hook()
stale_days = hook.get_records( stale_days = hook.get_records(
""" """
@@ -237,10 +245,6 @@ def assert_check(check_name: str, diagnostics: list[str]) -> None:
start_date=START_DATE, start_date=START_DATE,
is_paused_upon_creation=False, is_paused_upon_creation=False,
max_active_runs=1, max_active_runs=1,
# Три одновременные сверки на живом мире исчерпали 640 МиБ scheduler и
# получили SIGKILL. Последовательность здесь дешева: весь даг укладывается
# в один учебный прогон, а общему донору параллельность ничего не даёт.
max_active_tasks=1,
template_searchpath=str(SQL_ROOT), template_searchpath=str(SQL_ROOT),
tags=["dq"], tags=["dq"],
) )
@@ -253,9 +257,7 @@ def dq_check():
do_xcom_push=False, do_xcom_push=False,
) )
sessions = build_sql_check.override(task_id="build_sessions_vs_reference")( sessions = build_sessions_vs_reference()
"sessions_vs_reference", "sessions_vs_reference.sql"
)
classes = build_classes_vs_inventory.override( classes = build_classes_vs_inventory.override(
task_id="build_classes_vs_inventory" task_id="build_classes_vs_inventory"
)() )()
@@ -280,9 +282,6 @@ def dq_check():
conn_id=CLICKHOUSE_CONNECTION, conn_id=CLICKHOUSE_CONNECTION,
sql="dq/replace.sql", sql="dq/replace.sql",
do_xcom_push=False, do_xcom_push=False,
# Каждая замена — отдельный процесс LocalExecutor. Последовательность
# держит даг в измеренном лимите памяти планировщика.
max_active_tis_per_dag=1,
).expand_kwargs(days) ).expand_kwargs(days)
sessions_assertion = assert_check.override(task_id="sessions_vs_reference")( sessions_assertion = assert_check.override(task_id="sessions_vs_reference")(
+21 -4
View File
@@ -19,16 +19,23 @@
Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел
памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и
о самой машине ничего не знает. Остальные `mem_limit` не меняются. о самой машине ничего не знает. На 1 августа остальные `mem_limit` не менялись.
Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются
статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`, статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`,
`AIRFLOW__CORE__PARALLELISM` и девять неизменённых `mem_limit`. Основания, `AIRFLOW__CORE__PARALLELISM` и восемь оставшихся `mem_limit`. Основания,
кроме отменённого, у них нет. Кто в них упрётся — меняет по первому кроме отменённого, у них нет. Кто в них упрётся — меняет по первому
свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus
сюда не входит: неделя истории — осознанный учебный выбор, и цена у него сюда не входит: неделя истории — осознанный учебный выбор, и цена у него
дисковая. дисковая.
27 августа таким пределом впервые стала коробка `airflow-scheduler`:
LocalExecutor исполняет в ней до четырех процессов задач одновременно, а
640 МиБ этого не вместили. Коробка поднята до 1,5 ГиБ. Ресурсные ограничения
из отдельных дагов удалены. Общий предел параллелизма уже задает Airflow.
`max_active_runs=1` остается там, где два запуска делили бы один донор и могли
повредить публикацию.
Ресурсный довод ADR 0001 отозван. Ресурсный довод ADR 0001 отозван.
## Почему ## Почему
@@ -94,8 +101,8 @@ ClickHouse сам понижает каждый до 512 МиБ, а на сте
заранее, и в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме заранее, и в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме
пределов, а работе с запасом. пределов, а работе с запасом.
Остальные девять лимитов не меняются, потому что ни один из них ни разу не На 1 августа остальные девять лимитов не менялись, потому что ни один из них
сработал. Снять их скопом — то же изменение без свидетельств, каким они были не сработал. Снять их скопом — то же изменение без свидетельств, каким они были
выставлены, только в обратную сторону. Лечение здесь не в том, чтобы стереть выставлены, только в обратную сторону. Лечение здесь не в том, чтобы стереть
числа, а в том, чтобы лишить их статуса закона: число без основания меняют, числа, а в том, чтобы лишить их статуса закона: число без основания меняют,
когда оно мешает, и не защищают как принятое решение. когда оно мешает, и не защищают как принятое решение.
@@ -151,3 +158,13 @@ to 2.00 GiB because the system has limited RAM` и такие же строки
Значение `max_server_memory_usage_to_ram_ratio` = 0,9 и то, что в cgroup предел Значение `max_server_memory_usage_to_ram_ratio` = 0,9 и то, что в cgroup предел
считается от коробки, а не от машины, сверены по документации ClickHouse через считается от коробки, а не от машины, сверены по документации ClickHouse через
MCP Context7. MCP Context7.
`airflow-scheduler` проверен отдельно 27 августа 2026 года. На свежем
контейнере две попытки запустить четыре DQ-сверки параллельно в коробке
640 МиБ дошли ровно до `memory.max` и дали `oom_kill=1`: сначала при выгрузке
57 567 ключей сессий в Python, затем и после переноса дневной агрегации в
ClickHouse. В коробке 1 ГиБ сам `dq_check` прошел, но снова коснулся верхней
границы (`memory.events.max=57`). Полный `etl_pipeline` без локальных
ограничений задач в коробке 1,5 ГиБ завершился успешно: пик - 1 013 760 000
байт, `memory.events.max=0`, `oom_kill=0`. Docker не резервирует разницу между
пиком и пределом, поэтому этот запас не увеличивает расход памяти в покое.
+14 -6
View File
@@ -195,12 +195,20 @@ Distributed-таблица вернула бы каждый непарный з
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет подтверждает, что до витрины доехал записанный в описи мир, но не проверяет
сам генератор: у настоящего магазина такого оракула нет. сам генератор: у настоящего магазина такого оракула нет.
Пять сборок пишут в общий донор. Только после их успеха даг заменяет все Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow
проверенные дневные партиции, затем именованные задачи читают опубликованную только сводку и малый срез диагностики. Техническая сверка с описью читает
сводку и падают при `failed_rows > 0`. Расхождение успевает сохраниться до небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их
красного завершения, а ошибка сборки не затирает прежний результат. Красная успеха даг заменяет проверенные дневные партиции. Затем именованные задачи
задача печатает не больше двадцати нарушенных ключей со значениями сторон; читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
полный список воспроизводится запросом проверки. сохраниться до красного завершения, а ошибка сборки не затирает прежний
результат. Красная задача печатает не больше двадцати нарушенных ключей за
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
business_key`.
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
Такой день удаляется из сводки отдельно: старая красная строка не должна
превращаться в историю попыток.
## Даг самопроверки ## Даг самопроверки
+42 -17
View File
@@ -23,24 +23,49 @@ WITH
any(known_users) AS known_users any(known_users) AS known_users
FROM dm.daily_traffic_v FROM dm.daily_traffic_v
GROUP BY report_date GROUP BY report_date
),
comparison AS
(
SELECT
coalesce(reference.report_date, actual.report_date) AS data_date,
toString(coalesce(reference.report_date, actual.report_date))
AS business_key,
isNotNull(reference.report_date) AS reference_present,
isNotNull(actual.report_date) AS actual_present,
toString(tuple(reference.visitors, reference.known_users))
AS reference_value,
concat(
toString(tuple(actual.visitors, actual.known_users)),
', rows=',
toString(actual.physical_rows)
) AS actual_value,
isNull(reference.report_date)
OR isNull(actual.report_date)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.visitors, actual.visitors)
OR NOT isNotDistinctFrom(reference.known_users, actual.known_users)
AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (report_date)
) )
SELECT SELECT
coalesce(reference.report_date, actual.report_date) AS data_date, data_date,
toString(coalesce(reference.report_date, actual.report_date)) AS business_key, countIf(reference_present) AS reference_rows,
isNotNull(reference.report_date) AS reference_present, countIf(actual_present) AS actual_rows,
isNotNull(actual.report_date) AS actual_present, countIf(failed) AS failed_rows,
toString(tuple(reference.visitors, reference.known_users)) AS reference_value, groupArrayIf(20)(
concat( concat(
toString(tuple(actual.visitors, actual.known_users)), toString(data_date),
', rows=', ' key=',
toString(actual.physical_rows) business_key,
) AS actual_value, ': эталон=',
isNull(reference.report_date) if(reference_present, reference_value, 'нет ключа'),
OR isNull(actual.report_date) ', объект=',
OR actual.physical_rows != 1 if(actual_present, actual_value, 'нет ключа')
OR NOT isNotDistinctFrom(reference.visitors, actual.visitors) ),
OR NOT isNotDistinctFrom(reference.known_users, actual.known_users) AS failed failed
FROM reference ) AS diagnostics
GLOBAL FULL OUTER JOIN actual USING (report_date) FROM comparison
GROUP BY data_date
ORDER BY data_date ORDER BY data_date
SETTINGS join_use_nulls = 1 SETTINGS join_use_nulls = 1
+57 -32
View File
@@ -57,39 +57,64 @@ WITH
GROUP BY GROUP BY
order_day, order_day,
order_id order_id
),
comparison AS
(
SELECT
coalesce(reference.order_day, actual.order_day) AS data_date,
coalesce(reference.order_id, actual.order_id) AS business_key,
isNotNull(reference.order_id) AS reference_present,
isNotNull(actual.order_id) AS actual_present,
toString(tuple(
reference.declared_revenue,
reference.items_total,
reference.status,
reference.mismatch_class
)) AS reference_value,
concat(
toString(tuple(
actual.declared_revenue,
actual.items_total,
actual.status,
actual.mismatch_class
)),
', rows=',
toString(actual.physical_rows)
) AS actual_value,
isNull(reference.order_id)
OR isNull(actual.order_id)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(
reference.declared_revenue,
actual.declared_revenue
)
OR NOT isNotDistinctFrom(reference.items_total, actual.items_total)
OR NOT isNotDistinctFrom(reference.status, actual.status)
OR NOT isNotDistinctFrom(
reference.mismatch_class,
actual.mismatch_class
) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (order_day, order_id)
) )
SELECT SELECT
coalesce(reference.order_day, actual.order_day) AS data_date,
coalesce(reference.order_id, actual.order_id) AS business_key,
isNotNull(reference.order_id) AS reference_present,
isNotNull(actual.order_id) AS actual_present,
toString(tuple(
reference.declared_revenue,
reference.items_total,
reference.status,
reference.mismatch_class
)) AS reference_value,
concat(
toString(tuple(
actual.declared_revenue,
actual.items_total,
actual.status,
actual.mismatch_class
)),
', rows=',
toString(actual.physical_rows)
) AS actual_value,
isNull(reference.order_id)
OR isNull(actual.order_id)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.declared_revenue, actual.declared_revenue)
OR NOT isNotDistinctFrom(reference.items_total, actual.items_total)
OR NOT isNotDistinctFrom(reference.status, actual.status)
OR NOT isNotDistinctFrom(reference.mismatch_class, actual.mismatch_class)
AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (order_day, order_id)
ORDER BY
data_date, data_date,
business_key countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' key=',
business_key,
': эталон=',
if(reference_present, reference_value, 'нет ключа'),
', объект=',
if(actual_present, actual_value, 'нет ключа')
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1 SETTINGS join_use_nulls = 1
+52 -22
View File
@@ -49,29 +49,59 @@ WITH
GROUP BY GROUP BY
report_date, report_date,
product_category product_category
),
comparison AS
(
SELECT
coalesce(reference.report_date, actual.report_date) AS data_date,
coalesce(reference.product_category, actual.product_category)
AS business_key,
isNotNull(reference.product_category) AS reference_present,
isNotNull(actual.product_category) AS actual_present,
toString(tuple(
reference.orders,
reference.units,
reference.revenue,
reference.aov
)) AS reference_value,
concat(
toString(tuple(
actual.orders,
actual.units,
actual.revenue,
actual.aov
)),
', rows=',
toString(actual.physical_rows)
) AS actual_value,
isNull(reference.product_category)
OR isNull(actual.product_category)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.orders, actual.orders)
OR NOT isNotDistinctFrom(reference.units, actual.units)
OR NOT isNotDistinctFrom(reference.revenue, actual.revenue)
OR NOT isNotDistinctFrom(reference.aov, actual.aov) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (report_date, product_category)
) )
SELECT SELECT
coalesce(reference.report_date, actual.report_date) AS data_date,
coalesce(reference.product_category, actual.product_category) AS business_key,
isNotNull(reference.product_category) AS reference_present,
isNotNull(actual.product_category) AS actual_present,
toString(tuple(reference.orders, reference.units, reference.revenue, reference.aov))
AS reference_value,
concat(
toString(tuple(actual.orders, actual.units, actual.revenue, actual.aov)),
', rows=',
toString(actual.physical_rows)
) AS actual_value,
isNull(reference.product_category)
OR isNull(actual.product_category)
OR actual.physical_rows != 1
OR NOT isNotDistinctFrom(reference.orders, actual.orders)
OR NOT isNotDistinctFrom(reference.units, actual.units)
OR NOT isNotDistinctFrom(reference.revenue, actual.revenue)
OR NOT isNotDistinctFrom(reference.aov, actual.aov) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (report_date, product_category)
ORDER BY
data_date, data_date,
business_key countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' key=',
business_key,
': эталон=',
if(reference_present, reference_value, 'нет ключа'),
', объект=',
if(actual_present, actual_value, 'нет ключа')
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1 SETTINGS join_use_nulls = 1
+33 -13
View File
@@ -1,7 +1,7 @@
-- DQ: эталонная сессия Метрики против сессии, нарезанной слоем DDS. -- DQ: эталонная сессия Метрики против сессии, нарезанной слоем DDS.
-- --
-- Строка результата — одна кука одного завершённого дня. Сравнение по ключу -- Сопоставление идет по куке внутри завершенного дня: лишняя сессия одной
-- не даёт лишней сессии одной куки скрыть потерянную сессию другой. -- куки не скрывает потерянную сессию другой. Результат содержит итог дня.
WITH WITH
(SELECT min(EventDate) FROM ods.event_v) AS first_source_day, (SELECT min(EventDate) FROM ods.event_v) AS first_source_day,
first_source_day + ({position:UInt16} - 1) AS last_lived_day, first_source_day + ({position:UInt16} - 1) AS last_lived_day,
@@ -28,18 +28,38 @@ WITH
GROUP BY GROUP BY
data_date, data_date,
client_id client_id
),
comparison AS
(
SELECT
coalesce(reference.data_date, actual.data_date) AS data_date,
toString(coalesce(reference.client_id, actual.client_id)) AS business_key,
isNotNull(reference.client_id) AS reference_present,
isNotNull(actual.client_id) AS actual_present,
toString(reference.sessions) AS reference_value,
toString(actual.sessions) AS actual_value,
NOT isNotDistinctFrom(reference.sessions, actual.sessions) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (data_date, client_id)
) )
SELECT SELECT
coalesce(reference.data_date, actual.data_date) AS data_date,
toString(coalesce(reference.client_id, actual.client_id)) AS business_key,
isNotNull(reference.client_id) AS reference_present,
isNotNull(actual.client_id) AS actual_present,
toString(reference.sessions) AS reference_value,
toString(actual.sessions) AS actual_value,
NOT isNotDistinctFrom(reference.sessions, actual.sessions) AS failed
FROM reference
GLOBAL FULL OUTER JOIN actual USING (data_date, client_id)
ORDER BY
data_date, data_date,
business_key countIf(reference_present) AS reference_rows,
countIf(actual_present) AS actual_rows,
countIf(failed) AS failed_rows,
groupArrayIf(20)(
concat(
toString(data_date),
' key=',
business_key,
': эталон=',
if(reference_present, reference_value, 'нет ключа'),
', объект=',
if(actual_present, actual_value, 'нет ключа')
),
failed
) AS diagnostics
FROM comparison
GROUP BY data_date
ORDER BY data_date
SETTINGS join_use_nulls = 1 SETTINGS join_use_nulls = 1