Quality Gates: встраивание row count, null check и drift-проверок

Барьеры качества данных встроенные в ETL-пайплайны: fail-fast и quarantine-стратегии, single-pass aggregation для null-check, schema drift detection, статистические и бизнес-правила, интеграция с Airflow и Delta Constraints

streaming

Введение: почему quality - это не «последний шаг», а часть архитектуры

Рассмотрим такой сценарий: Spark job завершился успешно - exit code 0, задача в Airflow стала зелёной, метрики кластера в норме. Но через три дня аналитики обнаруживают, что выручка за прошлую неделю занижена в два раза. Причина - в четверг бэкенд-команда изменила кодировку поля amount: вместо копеек начали слать рубли. ETL честно записал новые числа, join прошёл без ошибок, агрегат посчитался - всё технически корректно.

Это называется silent data failure - одна из самых опасных проблем в enterprise analytics. Spark не знает бизнес-смысла данных. Для него amount = 350 и amount = 35000 - одинаково валидные числа. Только человек или явно заданное правило способны сказать «это аномалия».

Quality Gates - автоматические барьеры проверки данных, встраиваемые непосредственно в код ETL/ELT пайплайнов. Они превращают пайплайн из «чёрного ящика» в прозрачную систему с явными инвариантами: если данные не соответствуют ожиданиям, пайплайн либо останавливается, либо изолирует проблемные строки. Ошибка обнаруживается немедленно, а не через три дня.


Часть 1. Концепция Quality Gates в Медальонной архитектуре

1.1 Природа data corruption в production пайплайнах

Прежде чем строить защиту, нужно понять от чего защищаемся. Источники повреждения данных делятся на несколько категорий.

Schema drift - изменение структуры входящих данных без уведомления. Бэкенд переименовал колонку user_id в userId. Kafka-топик получил новое поле. API вернул null там, где раньше всегда было значение. Spark с mergeSchema=true может проглотить такое изменение, но downstream-код, ожидающий конкретные имена, сломается.

Partial ingestion - частичная загрузка данных. Upstream сервис упал в 3:17, передав только 40% файлов. Spark успешно обработал то, что получил. Ни один exception не был брошен. Gold-слой посчитал выручку за половину транзакций.

Duplicate explosion - аномальное размножение строк. JOIN с таблицей, в которой нарушилась уникальность PK, превращает 10M строк в 300M. Count успешно вернул 300M, но все метрики на Gold стали в 30 раз больше реальных.

Broken CDC logic - ошибка в Change Data Capture. UPSERT не нашёл нужную строку и создал дубликат вместо обновления. Или DELETE-событие пришло с задержкой и «воскресил» удалённую запись.

Upstream API failures - нисходящий сервис временно вернул тестовые данные, заглушку или данные другого тенанта. Технически - валидный JSON, семантически - мусор.

1.2 Quality Gates как архитектурный элемент

Quality Gate - это не просто assert df.count() > 0. Это checkpoint между стадиями пайплайна с явно определённым поведением при нарушении инварианта.

Каждый слой Медальонной архитектуры имеет свои типы проверок.

Bronze - технический уровень:

  • Схема соответствует ожидаемой (типы, nullable, имена полей)
  • Файл/топик не пустой (ingestion не сломан)
  • Обязательные технические поля присутствуют (_ingested_at, _source_file)
  • Нет критических null в primary key полях

Silver - семантический уровень:

  • Бизнес-ключи уникальны (deduplication прошёл успешно)
  • Значения в допустимых диапазонах (amount > 0, created_at <= NOW())
  • Ссылочная целостность (user_id существует в users-таблице)
  • Распределение данных в норме (нет аномального drift)

Gold - бизнес-уровень:

  • Метрики в разумных пределах (выручка не упала на 80%)
  • Агрегаты консистентны с предыдущими периодами
  • SLA-метрики: данные актуальны до определённого момента

1.3 Стратегии обработки нарушений

Не все нарушения одинаково критичны. Нужна два режима реакции.

Fail-Fast (жёсткий барьер) - пайплайн немедленно падает с исключением. Подходит для критических нарушений: пустой батч (upstream сломан), критический schema drift, нулевые значения в primary key. Главное преимущество - гарантируется, что в Gold не попадут заведомо неверные данные. Недостаток - весь батч теряется, включая корректные записи.

Quarantine (мягкий барьер) - «грязные» строки изолируются в отдельную карантинную таблицу, «чистая» часть продолжает движение. Подходит для частичной деградации качества: 2% строк с невалидным country_code, незначительный null-ratio в необязательных полях. Преимущество - максимальная доступность данных. Недостаток - требует операционного процесса для разбора карантина.

1.4 Экономика проверок силами Spark

Выполнять quality checks средствами самого Spark экономичнее, чем выгружать данные во внешние системы, по нескольким причинам.

Данные уже в памяти кластера - дополнительный сетевой transfer не нужен. Spark может считать десятки метрик за один проход (agg() с несколькими агрегатами = один Stage). Проверки параллелизуются вместе с обработкой. Нет зависимости от внешней системы, которая сама может быть недоступна.


Часть 2. Row Count Gates: базовая, но критически важная защита

2.1 Зачем считать строки

Row Count Gate - самый простой и самый часто спасающий барьер. Он защищает от нескольких классов проблем.

Broken ingestion - источник передал 0 файлов или пустой топик. Spark запишет пустой Parquet. Gold посчитает нули. Все KPI-дашборды покажут провал.

Duplicate explosion - после JOIN количество строк выросло в 100 раз. Это типичный симптом нарушения уникальности в dimension-таблице.

Accidental truncation - кто-то случайно запустил DELETE или перезаписал партицию. Count покажет катастрофическое падение.

Partial load - в Airflow сработал upstream failure, загрузилось 15% данных. Absolute threshold поймает это раньше, чем аналитики.

2.2 Типы row count проверок

Абсолютный порог - простейший случай. Задаём минимально допустимое количество строк:

def assert_min_row_count(
    df: DataFrame,
    min_rows: int,
    stage: str = "unnamed",
) -> DataFrame:
    """
    Fail-fast барьер: пайплайн падает, если строк меньше min_rows.
    Возвращает исходный DataFrame, чтобы встраиваться в цепочку трансформаций.
    """
    actual = df.count()
    if actual < min_rows:
        raise ValueError(
            f"[QualityGate/{stage}] Row count too low: "
            f"expected >= {min_rows:,}, got {actual:,}"
        )
    return df

Использование в пайплайне:

bronze_df = spark.read.parquet(bronze_path)
bronze_df = assert_min_row_count(bronze_df, min_rows=1000, stage="bronze_orders")

Относительный порог - сравниваем с предыдущим батчем. Падение более чем на 30% за сутки - сигнал тревоги:

def assert_relative_row_count(
    current_df: DataFrame,
    baseline_count: int,
    max_drop_ratio: float = 0.30,
    stage: str = "unnamed",
) -> DataFrame:
    """
    Проверяем, что текущий батч не упал более чем на max_drop_ratio
    относительно baseline (например, вчерашнего батча).
    """
    actual = current_df.count()
    if baseline_count > 0:
        drop_ratio = (baseline_count - actual) / baseline_count
        if drop_ratio > max_drop_ratio:
            raise ValueError(
                f"[QualityGate/{stage}] Row count dropped by "
                f"{drop_ratio:.1%}: baseline={baseline_count:,}, "
                f"actual={actual:,}, threshold={max_drop_ratio:.1%}"
            )
    return current_df

Историческое сравнение - более устойчивый вариант, использующий скользящее среднее за 7 дней:

def get_historical_avg_count(
    spark: SparkSession,
    table: str,
    date_col: str,
    days: int = 7,
    reference_date: str = None,
) -> float:
    """
    Считаем среднее количество строк за последние N дней.
    Используем это как динамический baseline для row count gate.
    """
    ref = reference_date or "current_date()"
    query = f"""
        SELECT AVG(daily_count) as avg_count
        FROM (
            SELECT {date_col}, COUNT(*) as daily_count
            FROM {table}
            WHERE {date_col} >= date_sub({ref}, {days})
              AND {date_col} < {ref}
            GROUP BY {date_col}
        )
    """
    result = spark.sql(query).collect()
    return float(result[0]["avg_count"] or 0)

2.3 False positives: законный рост vs аномалия

Важно учитывать, что данные растут вместе с бизнесом. Новогодние распродажи дают 10x рост транзакций - это не аномалия, это норма. Жёсткий порог сработает как ложная тревога.

Несколько подходов к снижению false positives:

  • Сезонное сравнение: сравнивать с аналогичным периодом прошлого года, а не с вчерашним днём
  • Доверительный интервал: использовать avg ± 3 * stddev как допустимый диапазон
  • Soft alerting: при отклонении отправлять уведомление, но не останавливать пайплайн

Часть 3. Null Checks: контроль полноты данных

3.1 Почему null опасен в Spark

Null - это не просто отсутствие значения. В Spark null обладает специфическим поведением, которое приводит к скрытым ошибкам:

# Null propagation через арифметику
# 100 + null = null - выручка по заказу будет null!
df = df.withColumn("total", col("price") * col("quantity") + col("discount"))

# Null в GROUP BY создаёт отдельную группу
# Если user_id бывает null - это отдельная "группа" в агрегате
df.groupBy("user_id").agg(sum("amount"))

# Null в JOIN - строки не находят пары (null != null по SQL семантике)
orders.join(users, "user_id")  # строки с null user_id потеряются

Непроверенные null в бизнес-ключах - одна из главных причин «тихого» потери данных в аналитических пайплайнах.

3.2 Антипаттерн: отдельный count для каждой колонки

Начинающий подход к null-проверке выглядит так:

# АНТИПАТТЕРН: N отдельных Action-операций = N сканов датасета
for col_name in critical_cols:
    null_count = df.filter(col(col_name).isNull()).count()
    if null_count > 0:
        raise ValueError(f"Nulls found in {col_name}: {null_count}")

Проблема: каждый .count() - это отдельный Action, который запускает отдельный Spark Job с полным сканом DataFrame. Для 20 колонок = 20 сканов, 20 Stage, 20 выполнений shuffle. Это катастрофически неэффективно.

3.3 Паттерн Single-Pass Aggregation

Правильный подход - собрать все метрики за один проход данных:

from pyspark.sql import functions as F

def check_nulls_single_pass(
    df: DataFrame,
    critical_cols: list,
    nullable_cols: dict = None,
    stage: str = "unnamed",
) -> dict:
    """
    Подсчёт null-значений за один Action (один скан DataFrame).

    critical_cols - колонки, где любой null = ошибка.
    nullable_cols - {col_name: max_null_ratio}, допустимый процент null.

    Возвращает словарь {col_name: null_count} для логирования.
    """
    nullable_cols = nullable_cols or {}

    # Формируем список агрегатов за один проход
    agg_exprs = [
        F.count(F.when(F.col(c).isNull(), 1)).alias(c)
        for c in (critical_cols + list(nullable_cols.keys()))
    ]

    # Один Action = один Spark Job = один скан
    null_counts_row = df.agg(*agg_exprs).collect()[0]
    null_counts = {c: null_counts_row[c] for c in null_counts_row.__fields__}

    total_rows = df.count()  # можно совместить в один agg с COUNT(*)

    errors = []

    # Проверка критических колонок
    for col_name in critical_cols:
        count = null_counts.get(col_name, 0)
        if count > 0:
            errors.append(
                f"  {col_name}: {count:,} nulls (CRITICAL - must be 0)"
            )

    # Проверка допустимого null-ratio
    for col_name, max_ratio in nullable_cols.items():
        count = null_counts.get(col_name, 0)
        actual_ratio = count / total_rows if total_rows > 0 else 0
        if actual_ratio > max_ratio:
            errors.append(
                f"  {col_name}: null ratio {actual_ratio:.2%} "
                f"> threshold {max_ratio:.2%} ({count:,}/{total_rows:,})"
            )

    if errors:
        raise ValueError(
            f"[QualityGate/{stage}] Null check failed:\n" + "\n".join(errors)
        )

    return null_counts

Оптимизированная версия совмещает count() в одном agg-вызове:

def check_nulls_and_count_single_pass(
    df: DataFrame,
    critical_cols: list,
) -> tuple:
    """
    За один Action считаем и общее количество строк, и null по каждой колонке.
    Ключевая оптимизация: COUNT(*) + все COUNT(WHEN isNull) в одном select.
    """
    agg_exprs = [F.count(F.lit(1)).alias("__total__")] + [
        F.count(F.when(F.col(c).isNull(), 1)).alias(c)
        for c in critical_cols
    ]

    row = df.agg(*agg_exprs).collect()[0]
    total = row["__total__"]
    null_counts = {c: row[c] for c in critical_cols}

    return total, null_counts

Почему это работает быстрее: Spark компилирует весь блок agg() в один физический план. Данные читаются один раз, все агрегаты вычисляются параллельно в одном Stage. Разница в производительности: 20 отдельных count = 20 минут, single-pass = 1 минута на 500GB датасете.

3.4 Приоритизация null-severity

Null в разных полях имеет разную критичность. Нужна явная классификация:

# Конфигурация проверок для Bronze -> Silver

NULL_CHECK_CONFIG = {
    # Критические поля: любой null = Fail-Fast
    "critical": [
        "transaction_id",
        "user_id",
        "created_at",
        "amount",
    ],
    # Допустимые null с порогами
    "nullable": {
        "promo_code": 0.95,       # до 95% строк могут не иметь промокода
        "delivery_address": 0.30,  # до 30% - самовывоз
        "phone": 0.15,             # до 15% - не указали телефон
        "utm_source": 0.40,        # до 40% - прямые заходы
    },
}

Часть 4. Schema Drift Gate: защита от изменений структуры

4.1 Почему schema drift опасен

Schema drift - изменение структуры данных без координации с командой потребителей. Это происходит регулярно в любой растущей организации:

  • Backend-разработчик переименовал поле userId в user_id (camelCase → snake_case)
  • Добавилась новая колонка partner_id - Spark с mergeSchema=true примет её без вопросов, но Gold-агрегаты её не знают
  • Тип поля amount изменился с LONG на DOUBLE - деление перестало работать как ожидалось
  • Kafka Schema Registry обновил версию схемы - Avro-десериализация вернула нулевые значения

С точки зрения Spark: всё прошло успешно. С точки зрения бизнеса: данные повреждены.

4.2 Автоматический аудит схемы

Подход основан на сравнении текущей схемы с эталонным снимком (snapshot):

from pyspark.sql.types import StructType
import json

def extract_schema_snapshot(df: DataFrame) -> dict:
    """
    Преобразуем schema в сериализуемый словарь для хранения и сравнения.
    Используем json() для получения стабильного текстового представления.
    """
    return json.loads(df.schema.json())


def compare_schemas(
    current_df: DataFrame,
    expected_schema: StructType,
    strict: bool = True,
    stage: str = "unnamed",
) -> dict:
    """
    Сравниваем текущую схему DataFrame с эталонной.

    strict=True  - любое расхождение = ошибка (Fail-Fast)
    strict=False - только предупреждение, если новые колонки добавились;
                   ошибка - если существующие колонки изменились/пропали
    """
    current = {f.name: f for f in current_df.schema.fields}
    expected = {f.name: f for f in expected_schema.fields}

    missing_cols = []     # колонки исчезли из источника
    type_changes = []     # тип колонки изменился
    new_cols = []         # появились новые колонки

    for name, exp_field in expected.items():
        if name not in current:
            missing_cols.append(name)
        elif current[name].dataType != exp_field.dataType:
            type_changes.append(
                f"  {name}: expected {exp_field.dataType}, "
                f"got {current[name].dataType}"
            )

    for name in current:
        if name not in expected:
            new_cols.append(name)

    report = {
        "missing_columns": missing_cols,
        "type_changes": type_changes,
        "new_columns": new_cols,
        "is_valid": not missing_cols and not type_changes,
    }

    # Критические нарушения - всегда Fail-Fast
    errors = []
    if missing_cols:
        errors.append(f"Missing columns: {missing_cols}")
    if type_changes:
        errors.append("Type changes detected:\n" + "\n".join(type_changes))

    if errors:
        raise ValueError(
            f"[QualityGate/{stage}] Schema drift detected:\n"
            + "\n".join(errors)
        )

    # Новые колонки - зависит от режима
    if new_cols and strict:
        raise ValueError(
            f"[QualityGate/{stage}] Unexpected new columns: {new_cols}. "
            f"Update expected schema or set strict=False."
        )
    elif new_cols:
        print(
            f"[QualityGate/{stage}] WARNING: New columns detected: {new_cols}. "
            f"Notify data producers."
        )

    return report

4.3 Хранение эталонных схем

Эталонные схемы нужно версионировать. Самый простой способ - хранить JSON-снимки рядом с кодом пайплайна:

# schemas/silver_orders_v2.json - хранится в git-репозитории

SILVER_ORDERS_SCHEMA_JSON = """
{
  "type": "struct",
  "fields": [
    {"name": "order_id", "type": "string", "nullable": false},
    {"name": "user_id", "type": "long", "nullable": false},
    {"name": "amount", "type": "double", "nullable": false},
    {"name": "status", "type": "string", "nullable": false},
    {"name": "created_at", "type": "timestamp", "nullable": false},
    {"name": "updated_at", "type": "timestamp", "nullable": true}
  ]
}
"""

def load_expected_schema(schema_json: str) -> StructType:
    return StructType.fromJson(json.loads(schema_json))

# В пайплайне:
expected = load_expected_schema(SILVER_ORDERS_SCHEMA_JSON)
compare_schemas(bronze_df, expected, strict=False, stage="bronze_to_silver")

Часть 5. Data Drift: когда схема правильная, но данные аномальные

5.1 Понятие Data Drift

Data Drift - ситуация, когда структура данных корректна, но распределение значений аномально изменилось. Это тонкое и опасное явление:

  • Средний чек вырос с 1500₽ до 150 000₽ - бэкенд перешёл с копеек на рубли
  • Доля статуса paid упала с 70% до 5% - сломался payment gateway
  • Количество уникальных user_id за день: вчера 1M, сегодня 15K - сломался login-сервис
  • Среднее время сессии выросло с 8 минут до 480 минут - баг в трекинге

5.2 Статистические барьеры

Spark предоставляет встроенные методы для расчёта статистики:

from pyspark.sql.functions import (
    min, max, avg, stddev, count, countDistinct, percentile_approx
)

def compute_numerical_stats(
    df: DataFrame,
    numerical_cols: list,
) -> dict:
    """
    Вычисляем статистику по числовым колонкам за один проход.
    Используем approx функции там, где точность не критична.
    """
    agg_exprs = []
    for c in numerical_cols:
        agg_exprs.extend([
            F.min(c).alias(f"{c}_min"),
            F.max(c).alias(f"{c}_max"),
            F.avg(c).alias(f"{c}_avg"),
            F.stddev(c).alias(f"{c}_stddev"),
            F.percentile_approx(c, 0.5).alias(f"{c}_median"),
            F.percentile_approx(c, 0.99).alias(f"{c}_p99"),
        ])

    row = df.agg(*agg_exprs).collect()[0]

    stats = {}
    for c in numerical_cols:
        stats[c] = {
            "min": row[f"{c}_min"],
            "max": row[f"{c}_max"],
            "avg": row[f"{c}_avg"],
            "stddev": row[f"{c}_stddev"],
            "median": row[f"{c}_median"],
            "p99": row[f"{c}_p99"],
        }
    return stats

Сравнение с историческим baseline:

def assert_no_numerical_drift(
    current_stats: dict,
    baseline_stats: dict,
    col_name: str,
    max_avg_deviation: float = 2.0,
    stage: str = "unnamed",
) -> None:
    """
    Проверяем, что среднее значение не отклонилось более чем в max_avg_deviation раз
    от исторического baseline.

    Пример: max_avg_deviation=2.0 означает, что avg может вырасти
    не более чем в 2 раза или упасть не более чем в 2 раза.
    """
    current_avg = current_stats[col_name]["avg"]
    baseline_avg = baseline_stats[col_name]["avg"]

    if baseline_avg is None or baseline_avg == 0:
        return  # нет исторических данных - пропускаем

    ratio = current_avg / baseline_avg

    if ratio > max_avg_deviation or ratio < (1.0 / max_avg_deviation):
        raise ValueError(
            f"[QualityGate/{stage}] Data drift in '{col_name}': "
            f"avg changed {ratio:.1f}x "
            f"(baseline={baseline_avg:.2f}, current={current_avg:.2f})"
        )

5.3 Drift для категориальных признаков

Для строковых колонок с конечным числом значений (статусы, типы, коды) важно следить за распределением:

def compute_categorical_distribution(
    df: DataFrame,
    col_name: str,
    top_n: int = 50,
) -> dict:
    """
    Вычисляем доли каждого значения в колонке.
    Возвращает словарь {value: ratio}.
    """
    total = df.count()
    dist = (
        df.groupBy(col_name)
        .count()
        .withColumn("ratio", F.col("count") / total)
        .orderBy(F.col("count").desc())
        .limit(top_n)
        .collect()
    )
    return {str(row[col_name]): row["ratio"] for row in dist}


def assert_no_categorical_drift(
    current_dist: dict,
    baseline_dist: dict,
    col_name: str,
    max_ratio_change: float = 0.50,
    stage: str = "unnamed",
) -> None:
    """
    Проверяем, что доля каждого значения не изменилась более чем на max_ratio_change.

    Пример: если baseline[paid] = 0.70, то current[paid] должен быть в диапазоне
    [0.35, 1.0] при max_ratio_change=0.50.
    """
    alerts = []
    for value, baseline_ratio in baseline_dist.items():
        current_ratio = current_dist.get(value, 0.0)
        if baseline_ratio > 0:
            change = abs(current_ratio - baseline_ratio) / baseline_ratio
            if change > max_ratio_change:
                alerts.append(
                    f"  '{value}': {baseline_ratio:.1%} -> {current_ratio:.1%} "
                    f"(delta {change:.0%})"
                )

    # Новые неожиданные значения
    new_values = set(current_dist.keys()) - set(baseline_dist.keys())
    if new_values:
        alerts.append(f"  New values appeared: {new_values}")

    if alerts:
        raise ValueError(
            f"[QualityGate/{stage}] Categorical drift in '{col_name}':\n"
            + "\n".join(alerts)
        )

Часть 6. Бизнес-правила: семантическая валидация

6.1 SQL Expressions как универсальный язык правил

Бизнес-правила удобно выражать через SQL-выражения - их можно хранить в конфигурации, а не в коде:

from dataclasses import dataclass

@dataclass
class QualityRule:
    name: str               # Идентификатор правила
    expression: str         # SQL-выражение, True = строка валидна
    severity: str           # "critical" | "warning"
    description: str        # Человекочитаемое описание

# Правила для Silver Orders
SILVER_ORDERS_RULES = [
    QualityRule(
        name="positive_amount",
        expression="amount > 0",
        severity="critical",
        description="Сумма заказа должна быть положительной",
    ),
    QualityRule(
        name="valid_status",
        expression="status IN ('paid', 'refunded', 'pending', 'cancelled')",
        severity="critical",
        description="Статус должен быть из допустимого справочника",
    ),
    QualityRule(
        name="delivery_after_order",
        expression="delivery_date IS NULL OR delivery_date >= created_at",
        severity="critical",
        description="Дата доставки не может быть раньше даты заказа",
    ),
    QualityRule(
        name="reasonable_amount",
        expression="amount < 10000000",
        severity="warning",
        description="Сумма заказа > 10M - возможная ошибка или мошенничество",
    ),
    QualityRule(
        name="not_future_order",
        expression="created_at <= current_timestamp()",
        severity="critical",
        description="Заказ не может быть из будущего",
    ),
]

6.2 Универсальный валидатор с Single-Pass Aggregation

Ключевая идея: проверяем все правила за один скан DataFrame. Для каждого правила считаем количество нарушителей через COUNT(WHEN NOT condition):

from pyspark.sql import DataFrame
from pyspark.sql.functions import expr, count, when, lit

def run_quality_rules(
    df: DataFrame,
    rules: list,
    mode: str = "fail_fast",
    stage: str = "unnamed",
) -> tuple:
    """
    Запускает все правила за один Action.

    mode="fail_fast"   - поднимает exception при первом критическом нарушении
    mode="quarantine"  - разделяет DataFrame на valid и invalid

    Возвращает (valid_df, invalid_df, quality_report).
    """
    # Строим флаги нарушений для каждого правила
    # Добавляем булевую колонку per_rule_violated
    flagged_df = df
    for rule in rules:
        flagged_df = flagged_df.withColumn(
            f"_violated_{rule.name}",
            ~expr(rule.expression),  # True если правило НАРУШЕНО
        )

    # Считаем нарушения за один Action
    agg_exprs = [
        count(when(F.col(f"_violated_{rule.name}"), lit(1))).alias(rule.name)
        for rule in rules
    ] + [count(lit(1)).alias("__total__")]

    stats_row = flagged_df.agg(*agg_exprs).collect()[0]
    total = stats_row["__total__"]

    quality_report = {
        "total_rows": total,
        "violations": {},
        "is_valid": True,
        "stage": stage,
    }

    critical_violations = []
    warning_violations = []

    for rule in rules:
        violation_count = stats_row[rule.name]
        violation_ratio = violation_count / total if total > 0 else 0

        quality_report["violations"][rule.name] = {
            "count": violation_count,
            "ratio": violation_ratio,
            "severity": rule.severity,
            "description": rule.description,
        }

        if violation_count > 0:
            msg = (
                f"  [{rule.severity.upper()}] {rule.name}: "
                f"{violation_count:,} rows ({violation_ratio:.2%}) "
                f"— {rule.description}"
            )
            if rule.severity == "critical":
                critical_violations.append(msg)
                quality_report["is_valid"] = False
            else:
                warning_violations.append(msg)

    # Логируем предупреждения
    if warning_violations:
        print(
            f"[QualityGate/{stage}] Warnings:\n"
            + "\n".join(warning_violations)
        )

    if mode == "fail_fast" and critical_violations:
        raise ValueError(
            f"[QualityGate/{stage}] Critical violations found:\n"
            + "\n".join(critical_violations)
        )

    if mode == "quarantine":
        # Разделяем: строка плохая, если нарушает хотя бы одно critical правило
        violation_condition = None
        for rule in rules:
            if rule.severity == "critical":
                cond = F.col(f"_violated_{rule.name}")
                violation_condition = (
                    cond if violation_condition is None
                    else violation_condition | cond
                )

        # Удаляем служебные колонки
        flag_cols = [f"_violated_{rule.name}" for rule in rules]

        if violation_condition is not None:
            valid_df = flagged_df.filter(~violation_condition).drop(*flag_cols)
            invalid_df = flagged_df.filter(violation_condition).drop(*flag_cols)
        else:
            valid_df = flagged_df.drop(*flag_cols)
            invalid_df = df.limit(0)  # пустой DataFrame

        return valid_df, invalid_df, quality_report

    # Режим fail_fast: возвращаем исходный DataFrame без флагов
    flag_cols = [f"_violated_{rule.name}" for rule in rules]
    return df, None, quality_report

6.3 Диаграмма потока валидации


Часть 7. Fail-Fast vs Quarantine: операционные паттерны

7.1 Когда применять Fail-Fast

Fail-Fast оправдан, когда:

Данные неотделимо повреждены - невозможно определить, какая часть батча «хорошая». Например, если schema drift затронул все строки, нельзя взять «половину» с правильной схемой.

Downstream использование неотложно - Gold-витрина кормит real-time дашборд с SLA, нельзя допустить частично неверные данные.

Нарушение абсолютное - пустой батч, 100% null в PK, отрицательные суммы во всех строках.

def assert_quality_gate(
    df: DataFrame,
    rules: list,
    stage: str,
) -> DataFrame:
    """
    Fail-Fast Quality Gate.
    Бросает ValueError при первом критическом нарушении.
    Возвращает исходный DataFrame для чейнинга.
    """
    _, _, report = run_quality_rules(df, rules, mode="fail_fast", stage=stage)

    print(
        f"[QualityGate/{stage}] PASSED: "
        f"{report['total_rows']:,} rows, "
        f"{len([r for r in report['violations'].values() if r['count'] == 0])} "
        f"rules OK"
    )
    return df

7.2 Quarantine Pattern: изоляция «плохих» строк

Quarantine подходит для ситуаций, когда большинство данных корректны, но часть нарушает правила:

from pyspark.sql.functions import current_timestamp, lit

def apply_quarantine_gate(
    df: DataFrame,
    rules: list,
    quarantine_path: str,
    stage: str,
    batch_date: str,
) -> DataFrame:
    """
    Quarantine Quality Gate.

    Разделяет DataFrame на valid/invalid.
    Invalid строки записываются в карантинную таблицу с метаданными.
    Возвращает только valid строки для дальнейшей обработки.
    """
    valid_df, invalid_df, report = run_quality_rules(
        df, rules, mode="quarantine", stage=stage
    )

    total = report["total_rows"]

    if invalid_df is not None:
        # Обогащаем карантинные строки метаданными для последующего разбора
        quarantine_df = invalid_df.withColumns({
            "_quarantine_stage": lit(stage),
            "_quarantine_date": lit(batch_date),
            "_quarantine_ts": current_timestamp(),
            "_quarantine_rules": lit(
                str([r.name for r in rules if r.severity == "critical"])
            ),
        })

        quarantine_count = quarantine_df.count()
        if quarantine_count > 0:
            (
                quarantine_df
                .write
                .mode("append")
                .partitionBy("_quarantine_date", "_quarantine_stage")
                .parquet(quarantine_path)
            )
            print(
                f"[QualityGate/{stage}] Quarantine: "
                f"{quarantine_count:,}/{total:,} rows isolated "
                f"({quarantine_count/total:.1%})"
            )

    valid_count = valid_df.count()
    print(
        f"[QualityGate/{stage}] Passing to next stage: "
        f"{valid_count:,}/{total:,} rows ({valid_count/total:.1%})"
    )

    return valid_df

7.3 Выбор стратегии по типу нарушения


Часть 8. Delta Lake Constraints: встроенная защита на уровне таблицы

8.1 CHECK Constraints в Delta Lake

Delta Lake начиная с версии 1.0 поддерживает CHECK constraints - SQL-выражения, которые проверяются при каждой записи. Если строка нарушает constraint, операция записи целиком откатывается.

-- Добавляем ограничение на существующую таблицу
ALTER TABLE silver.orders
ADD CONSTRAINT positive_amount
CHECK (amount > 0);

ALTER TABLE silver.orders
ADD CONSTRAINT valid_status
CHECK (status IN ('paid', 'refunded', 'pending', 'cancelled'));

ALTER TABLE silver.orders
ADD CONSTRAINT delivery_after_order
CHECK (delivery_date IS NULL OR delivery_date >= created_at);

Из PySpark:

def add_delta_constraints(
    spark: SparkSession,
    table_name: str,
    constraints: dict,
) -> None:
    """
    Добавляем Delta CHECK constraints программно.
    constraints = {"constraint_name": "sql_expression"}
    """
    for name, expression in constraints.items():
        spark.sql(
            f"ALTER TABLE {table_name} ADD CONSTRAINT {name} CHECK ({expression})"
        )
        print(f"Added constraint: {name} -> {expression}")


add_delta_constraints(
    spark,
    "silver.orders",
    {
        "positive_amount": "amount > 0",
        "valid_status": "status IN ('paid', 'refunded', 'pending', 'cancelled')",
        "not_future_created": "created_at <= current_timestamp()",
    },
)

При попытке записать нарушающую строку Delta бросает DeltaInvariantViolationException:

org.apache.spark.sql.delta.schema.DeltaInvariantViolationException:
CHECK constraint positive_amount (amount > 0) violated by row with values:
 - amount : -100.0

8.2 Важная особенность: Constraints блокируют всю запись

Delta CHECK constraints работают по принципу «всё или ничего». Если хотя бы одна строка нарушает правило, вся транзакция откатывается. Это Fail-Fast на уровне хранилища.

Для Quarantine-паттерна constraints не подходят - нужно выполнять проверку в Spark ДО записи и отфильтровывать плохие строки.

Правило проектирования:

  • Delta Constraints - защита от случайных ошибок, последний рубеж обороны
  • Pre-write Quality Gates - основная бизнес-логика проверок, разделение valid/invalid

8.3 Просмотр существующих Constraints

# Все constraints таблицы видны в SHOW TBLPROPERTIES
spark.sql("SHOW TBLPROPERTIES silver.orders")

# Или через describe detail
spark.sql("DESCRIBE DETAIL silver.orders").select("properties").show(truncate=False)

Часть 9. Интеграция с Airflow: оркестрация Quality Gates

9.1 Quality Gate как отдельная Airflow-задача

Quality Gates должны быть явными задачами в DAG - не зарыты внутри обработки, а видимы в графе зависимостей:

from airflow import DAG
from airflow.operators.python import BranchPythonOperator, PythonOperator
from airflow.providers.apache.spark.operators.spark_submit import SparkSubmitOperator
from airflow.utils.dates import days_ago
from datetime import timedelta

with DAG(
    dag_id="silver_orders_pipeline",
    schedule_interval="0 6 * * *",
    start_date=days_ago(1),
    catchup=False,
    default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
    tags=["silver", "orders"],
) as dag:

    # 1. Ingestion Bronze
    ingest_bronze = SparkSubmitOperator(
        task_id="ingest_bronze_orders",
        application="jobs/ingest_bronze_orders.py",
        application_args=["--date", "{{ ds }}"],
        conf={"spark.executor.memory": "4g"},
        pool="spark_heavy_pool",
    )

    # 2. Quality Gate: Bronze проверки (лёгкий job)
    bronze_quality_gate = SparkSubmitOperator(
        task_id="bronze_quality_gate",
        application="jobs/quality_gates/bronze_orders_gate.py",
        application_args=["--date", "{{ ds }}"],
        conf={"spark.executor.memory": "2g"},
        pool="spark_light_pool",
    )

    # 3. Branching: что делать при нарушении
    def route_on_quality_result(**context):
        """
        Читаем результат качества из XCom и решаем: продолжать или нет.
        В продакшне качество пишется в Metadata-таблицу.
        """
        ti = context["ti"]
        quality_passed = ti.xcom_pull(
            task_ids="bronze_quality_gate",
            key="quality_passed",
        )
        if quality_passed:
            return "transform_silver"
        else:
            return "notify_quality_failure"

    quality_branch = BranchPythonOperator(
        task_id="quality_branch",
        python_callable=route_on_quality_result,
        provide_context=True,
    )

    # 4a. Путь успеха: трансформация в Silver
    transform_silver = SparkSubmitOperator(
        task_id="transform_silver",
        application="jobs/transform_silver_orders.py",
        application_args=["--date", "{{ ds }}"],
        conf={"spark.executor.memory": "8g"},
        pool="spark_heavy_pool",
    )

    # 4b. Путь ошибки: уведомление команды
    notify_failure = PythonOperator(
        task_id="notify_quality_failure",
        python_callable=lambda: print("Alerting quality failure..."),
    )

    # 5. Silver Quality Gate
    silver_quality_gate = SparkSubmitOperator(
        task_id="silver_quality_gate",
        application="jobs/quality_gates/silver_orders_gate.py",
        application_args=["--date", "{{ ds }}"],
        pool="spark_light_pool",
    )

    # Граф зависимостей
    ingest_bronze >> bronze_quality_gate >> quality_branch
    quality_branch >> [transform_silver, notify_failure]
    transform_silver >> silver_quality_gate

9.2 Передача результатов через коды возврата

Quality Gate Spark-job сигнализирует результат через exit code. Airflow по умолчанию помечает задачу как failed при non-zero коде возврата:

# jobs/quality_gates/bronze_orders_gate.py

import sys
import json
from pyspark.sql import SparkSession


def main():
    spark = SparkSession.builder.appName("BronzeOrdersQualityGate").getOrCreate()

    date = sys.argv[sys.argv.index("--date") + 1]
    bronze_path = f"s3a://datalake/bronze/orders/date={date}/"

    df = spark.read.parquet(bronze_path)

    quality_report = {
        "date": date,
        "stage": "bronze_orders",
        "checks": {},
        "passed": True,
    }

    # Row Count Gate
    row_count = df.count()
    quality_report["checks"]["row_count"] = {
        "value": row_count,
        "threshold": 1000,
        "passed": row_count >= 1000,
    }
    if row_count < 1000:
        quality_report["passed"] = False

    # Null Gate - single pass
    _, null_counts = check_nulls_and_count_single_pass(
        df, ["order_id", "user_id", "amount"]
    )
    for col_name, null_count in null_counts.items():
        quality_report["checks"][f"null_{col_name}"] = {
            "value": null_count,
            "threshold": 0,
            "passed": null_count == 0,
        }
        if null_count > 0:
            quality_report["passed"] = False

    # Запись в Metadata-таблицу (для дашборда)
    quality_df = spark.createDataFrame([quality_report])
    (
        quality_df
        .write
        .mode("append")
        .format("delta")
        .saveAsTable("metadata.quality_reports")
    )

    # Для Airflow: non-zero exit = failure
    if not quality_report["passed"]:
        print(f"QUALITY_RESULT:FAILED:{json.dumps(quality_report)}")
        sys.exit(1)  # Airflow поймает это как task failure
    else:
        print(f"QUALITY_RESULT:PASSED:{json.dumps(quality_report)}")
        sys.exit(0)


if __name__ == "__main__":
    main()

9.3 Общий DAG с полным Quality Gating


Часть 10. Специализированные библиотеки

10.1 Когда кастомный код заменяют библиотекой

Написанный нами код покрывает базовые потребности. Но в больших организациях появляются дополнительные требования:

  • Централизованное хранилище правил с версионированием
  • Визуальные дашборды качества для аналитиков (без доступа к коду)
  • Автоматическая документация ожиданий
  • Интеграция с data catalog (DataHub, OpenMetadata)
  • Сложные cross-table проверки ссылочной целостности

Тогда рассматривают специализированные инструменты.

10.2 Amazon Deequ и PyDeequ

Amazon Deequ (Java/Scala, есть Python-обёртка PyDeequ) - библиотека от Amazon для измерения качества на масштабе. Работает поверх Spark, генерирует метрики (Analyzers) и проверяет ограничения (Checks):

from pydeequ.analyzers import AnalysisRunner, Size, Completeness, Uniqueness
from pydeequ.checks import Check, CheckLevel
from pydeequ.verification import VerificationSuite

# Определяем проверки
check = (
    Check(spark, CheckLevel.Error, "Order Quality")
    .hasSize(lambda x: x >= 1000, "Too few rows")
    .isComplete("order_id")
    .isComplete("user_id")
    .isUnique("order_id")
    .isContainedIn("status", ["paid", "refunded", "pending", "cancelled"])
    .isPositive("amount")
    .isNonNegative("discount")
)

# Запускаем верификацию
result = (
    VerificationSuite(spark)
    .onData(df)
    .addCheck(check)
    .run()
)

# Анализируем результат
from pydeequ.verification import VerificationResult
result_df = VerificationResult.checkResultsAsDataFrame(spark, result)
result_df.show()

Преимущества Deequ: встроенные Analyzers кешируют метрики (вычисляет за один проход), поддерживает Anomaly Detection с историческим репозиторием метрик, хорошо интегрируется с AWS Glue.

10.3 Great Expectations: стандарт open-source data quality

Great Expectations (GX) - наиболее распространённый open-source инструмент. Имеет визуальный UI для Data Docs, богатую библиотеку expectations, интеграцию с Airflow через провайдеры:

import great_expectations as gx

# Контекст - центральный объект GX
context = gx.get_context()

# Создаём Data Source для Spark DataFrame
data_source = context.sources.add_spark("spark_source")
data_asset = data_source.add_dataframe_asset("orders")

# Определяем Expectation Suite
suite = context.add_expectation_suite("silver_orders")

# Добавляем ожидания
suite.expect_column_values_to_not_be_null("order_id")
suite.expect_column_values_to_be_in_set(
    "status", ["paid", "refunded", "pending", "cancelled"]
)
suite.expect_column_values_to_be_between("amount", min_value=0.01)
suite.expect_table_row_count_to_be_between(min_value=1000)

# Валидируем
batch_request = data_asset.build_batch_request(dataframe=df)
results = context.run_validation_operator(
    "action_list_operator",
    assets_to_validate=[batch_request],
    run_name="daily_run",
)

Ключевое преимущество GX - Data Docs: автоматически генерируемые HTML-страницы с документацией всех expectations и историей их выполнения. Это bridge между инженерами и аналитиками.

10.4 Выбор инструмента

Критерий Кастомный PySpark Deequ / PyDeequ Great Expectations
Скорость старта Мгновенно Несколько часов 1-2 дня
Гибкость Максимальная Средняя Средняя
Визуальный UI Нет Нет Data Docs
Производительность Зависит от реализации Оптимизирован Средняя
Интеграция с каталогом Ручная AWS Glue Многие
Кривая обучения Низкая Средняя Высокая

Рекомендация: начинайте с кастомного кода - это даёт полный контроль и понимание происходящего. Переходите на специализированную библиотеку, когда в команде более 5 data engineers и более 50 таблиц с quality rules.


Часть 11. Anti-Patterns в Data Quality

11.1 Hardcoded Thresholds без контекста

# АНТИПАТТЕРН: магические числа без объяснения
if df.count() < 50000:
    raise ValueError("Too few rows")

# ПРАВИЛЬНО: именованные константы с документацией
# Исторический минимум в non-holiday weekday
# Источник: analytics_report_2024-01.pdf, page 12
# Обновить при существенном изменении бизнеса
MIN_DAILY_ORDERS = 50_000

if df.count() < MIN_DAILY_ORDERS:
    raise ValueError(
        f"Row count below historical minimum: {MIN_DAILY_ORDERS:,}"
    )

11.2 Alert Fatigue: слишком много шума

Если quality gates срабатывают часто из-за нормальной бизнес-волатильности, команда начинает игнорировать алерты. Это хуже отсутствия алертов - формируется «сигнальный шум».

# АНТИПАТТЕРН: бинарный threshold без учёта дня недели
if current_count < 50000:
    raise ValueError()  # срабатывает каждый понедельник из-за выходных

# ПРАВИЛЬНО: динамический baseline с учётом дня недели
def get_dow_adjusted_baseline(table: str, day_of_week: int) -> int:
    """
    Разные пороги для будней/выходных.
    Понедельник: исторически на 20% меньше из-за выходных.
    Берём исторический avg только за те же дни недели.
    """
    base = get_historical_avg_count(
        table, days=28, same_dow=True
    )
    return int(base * 0.70)  # допускаем падение до 30% от DOW-avg

11.3 Excessive Strictness на Bronze

Bronze - это «сырая» зона, raw данные «как есть». Чрезмерные проверки на Bronze контрпродуктивны:

# АНТИПАТТЕРН: строгая валидация бизнес-правил на Bronze
# Bronze должен принимать всё, включая «плохие» данные
bronze_df = assert_quality_gate(
    df,
    [
        QualityRule(
            "valid_status",
            "status IN ('paid', 'refunded')",
            "critical",
            "...",
        ),
    ],
    stage="bronze",
)
# Результат: Bronze не принял данные из источника-нарушителя.
# Теперь мы потеряли сырые данные и не можем расследовать проблему.

# ПРАВИЛЬНО: Bronze принимает всё, Silver фильтрует
# Bronze gate: только техническая валидность (схема, non-null PK)
# Silver gate: бизнес-правила + quarantine

11.4 «Green but Wrong» тесты

Самый опасный антипаттерн - quality gates, которые всегда проходят, но не проверяют ничего содержательного:

# АНТИПАТТЕРН: бессмысленная проверка
def assert_not_empty(df):
    assert df.count() >= 0  # Всегда True!

# АНТИПАТТЕРН: проверка без assert
def check_nulls(df, cols):
    null_counts = {c: df.filter(col(c).isNull()).count() for c in cols}
    return null_counts  # Считает, но никогда не падает!

# ПРАВИЛЬНО: явные assertions с условиями отказа
def assert_no_critical_nulls(df, cols):
    _, null_counts = check_nulls_and_count_single_pass(df, cols)
    for col_name, count in null_counts.items():
        assert count == 0, f"Found {count} nulls in critical column '{col_name}'"

11.5 Coupling Quality с Transform

Проверки и трансформации должны быть разделены:

# АНТИПАТТЕРН: качество перемешано с трансформацией
def process_orders(df):
    df = df.filter(col("amount") > 0)   # Молча удаляем строки с amount <= 0
    df = df.withColumn("total", col("amount") * col("qty"))
    df = df.dropDuplicates(["order_id"])  # Молча убиваем дубликаты
    return df
# Никто не знает, сколько строк потерялось. Quality "gate" невидим.

# ПРАВИЛЬНО: явный gate до трансформации
def process_orders(df, quarantine_path, batch_date):
    # Сначала - явный gate с отчётом
    df = apply_quarantine_gate(
        df,
        rules=ORDERS_RULES,
        quarantine_path=quarantine_path,
        stage="silver_orders",
        batch_date=batch_date,
    )
    # Потом - трансформация только над чистыми данными
    df = df.withColumn("total", col("amount") * col("qty"))
    return df

Часть 12. Production Case: Quality-Gated Lakehouse Pipeline

12.1 Постановка задачи

Рассмотрим реальный производственный сценарий: daily batch pipeline обрабатывает транзакционные события интернет-магазина.

  • Источник: S3 (JSON-файлы от бэкенда, ~5M событий в день)
  • Bronze: Raw events as-is
  • Silver: Validated, deduplicated, normalized
  • Gold: Daily KPI metrics для BI-дашборда

Требования к качеству:

  • Пайплайн не должен производить Gold-данные при сломанном источнике
  • Частично «грязные» батчи обрабатываются с quarantine
  • Все нарушения логируются и видны аналитикам

12.2 Конфигурация правил

# pipeline_config.py

from pyspark.sql.types import (
    StructType, StructField, StringType, LongType, DoubleType,
    TimestampType, IntegerType,
)

BRONZE_ORDERS_SCHEMA = StructType([
    StructField("order_id", StringType(), nullable=False),
    StructField("user_id", LongType(), nullable=False),
    StructField("amount", DoubleType(), nullable=False),
    StructField("currency", StringType(), nullable=True),
    StructField("status", StringType(), nullable=True),
    StructField("created_at", TimestampType(), nullable=False),
    StructField("updated_at", TimestampType(), nullable=True),
    StructField("items_count", IntegerType(), nullable=True),
    StructField("delivery_date", TimestampType(), nullable=True),
    StructField("promo_code", StringType(), nullable=True),
    StructField("_source_file", StringType(), nullable=True),
    StructField("_ingested_at", TimestampType(), nullable=True),
])

SILVER_ORDERS_RULES = [
    QualityRule(
        "positive_amount",
        "amount > 0",
        "critical",
        "Сумма заказа должна быть положительной",
    ),
    QualityRule(
        "valid_status",
        "status IN ('paid', 'refunded', 'pending', 'cancelled')",
        "critical",
        "Статус из допустимого справочника",
    ),
    QualityRule(
        "valid_currency",
        "currency IN ('RUB', 'USD', 'EUR')",
        "warning",
        "Валюта из поддерживаемых",
    ),
    QualityRule(
        "reasonable_amount",
        "amount < 10000000",
        "warning",
        "Сумма > 10M требует проверки",
    ),
    QualityRule(
        "delivery_after_order",
        "delivery_date IS NULL OR delivery_date >= created_at",
        "critical",
        "Дата доставки >= дата заказа",
    ),
    QualityRule(
        "not_future_created",
        "created_at <= current_timestamp()",
        "critical",
        "Заказ не из будущего",
    ),
]

12.3 Полный pipeline с Quality Gates

# jobs/daily_orders_pipeline.py

import sys
from datetime import datetime
from pyspark.sql import SparkSession
from pyspark.sql import functions as F


def run_pipeline(spark: SparkSession, batch_date: str) -> None:
    """Full Quality-Gated Pipeline: Bronze -> Silver -> Gold."""

    print(f"=== Pipeline start: {batch_date} ===")

    bronze_path = f"s3a://datalake/bronze/orders/date={batch_date}/"
    quarantine_path = "s3a://datalake/quarantine/orders/"
    silver_path = "s3a://datalake/silver/orders/"
    gold_path = "s3a://datalake/gold/daily_kpi/"

    # ─────────────────────────────────────────────────────────────────
    # BRONZE: Ingestion + техническая валидация
    # ─────────────────────────────────────────────────────────────────
    print("Step 1: Reading Bronze...")
    bronze_df = spark.read.schema(BRONZE_ORDERS_SCHEMA).json(bronze_path)

    # Gate 1a: Schema Drift - ломаем при критических расхождениях
    print("Gate 1a: Schema drift check...")
    compare_schemas(
        bronze_df,
        BRONZE_ORDERS_SCHEMA,
        strict=False,
        stage="bronze_ingestion",
    )

    # Gate 1b: Row Count - пустой батч = сломан источник
    print("Gate 1b: Row count check...")
    bronze_count = bronze_df.count()
    if bronze_count == 0:
        raise ValueError(
            f"[CRITICAL] Bronze is empty for {batch_date}. "
            "Upstream failure suspected."
        )
    print(f"  Bronze rows: {bronze_count:,}")

    # Gate 1c: Null Check на критических полях (single-pass)
    print("Gate 1c: Critical null check...")
    _, null_counts = check_nulls_and_count_single_pass(
        bronze_df,
        ["order_id", "user_id", "amount", "created_at"],
    )
    critical_nulls = {k: v for k, v in null_counts.items() if v > 0}
    if critical_nulls:
        raise ValueError(
            f"[CRITICAL] Null in PK/mandatory fields: {critical_nulls}. "
            "Upstream data is poisoned."
        )

    # ─────────────────────────────────────────────────────────────────
    # SILVER: Нормализация + бизнес-валидация с Quarantine
    # ─────────────────────────────────────────────────────────────────
    print("\nStep 2: Transforming to Silver...")
    silver_df = (
        bronze_df
        .dropDuplicates(["order_id"])
        .withColumn("processing_date", F.lit(batch_date))
    )

    # Gate 2: Бизнес-правила с Quarantine
    print("Gate 2: Business rules (quarantine mode)...")
    silver_valid, silver_invalid, silver_report = run_quality_rules(
        silver_df,
        SILVER_ORDERS_RULES,
        mode="quarantine",
        stage="silver_orders",
    )

    # Сохраняем карантин
    if silver_invalid is not None:
        quarantine_count = silver_invalid.count()
        if quarantine_count > 0:
            (
                silver_invalid
                .withColumn("_quarantine_date", F.lit(batch_date))
                .withColumn("_quarantine_ts", F.current_timestamp())
                .write
                .mode("append")
                .partitionBy("_quarantine_date")
                .parquet(quarantine_path)
            )
            print(f"  Quarantined: {quarantine_count:,} rows")

            # Если карантин > 20% - алерт, но продолжаем
            quarantine_ratio = quarantine_count / bronze_count
            if quarantine_ratio > 0.20:
                print(
                    f"  [WARNING] High quarantine ratio: {quarantine_ratio:.1%}. "
                    "Notify data producers."
                )

    # Записываем Silver
    valid_count = silver_valid.count()
    print(f"  Silver valid rows: {valid_count:,}")

    (
        silver_valid
        .write
        .mode("overwrite")
        .format("delta")
        .partitionBy("processing_date")
        .save(silver_path)
    )

    # Gate 2b: Финальный контроль - если потеряли > 50% строк, это уже Fail-Fast
    if valid_count < int(bronze_count * 0.50):
        raise ValueError(
            f"[CRITICAL] Silver has only {valid_count:,}/{bronze_count:,} rows "
            f"({valid_count/bronze_count:.1%}). Too many quarantined."
        )

    # ─────────────────────────────────────────────────────────────────
    # GOLD: Агрегации + финальные проверки
    # ─────────────────────────────────────────────────────────────────
    print("\nStep 3: Computing Gold KPIs...")
    gold_df = (
        spark.read.format("delta").load(silver_path)
        .filter(F.col("processing_date") == batch_date)
        .filter(F.col("status") == "paid")
        .agg(
            F.count("order_id").alias("total_orders"),
            F.sum("amount").alias("total_revenue"),
            F.avg("amount").alias("avg_order_value"),
            F.countDistinct("user_id").alias("unique_buyers"),
        )
        .withColumn("report_date", F.lit(batch_date))
    )

    # Gate 3: Проверка разумности Gold-метрик
    print("Gate 3: Gold metrics sanity check...")
    gold_row = gold_df.collect()[0]

    EXPECTED_MIN_REVENUE = 1_000_000  # 1M RUB минимальная выручка в день
    EXPECTED_MAX_AOV = 500_000        # 500K RUB максимальный средний чек

    if gold_row["total_revenue"] < EXPECTED_MIN_REVENUE:
        raise ValueError(
            f"[CRITICAL] Daily revenue too low: "
            f"{gold_row['total_revenue']:,.0f} RUB. "
            f"Expected >= {EXPECTED_MIN_REVENUE:,}"
        )

    if gold_row["avg_order_value"] > EXPECTED_MAX_AOV:
        raise ValueError(
            f"[CRITICAL] AOV suspiciously high: "
            f"{gold_row['avg_order_value']:,.0f} RUB. "
            f"Possible currency bug."
        )

    # Записываем Gold
    (
        gold_df
        .write
        .mode("overwrite")
        .format("delta")
        .partitionBy("report_date")
        .save(gold_path)
    )

    print(f"""
=== Pipeline COMPLETE: {batch_date} ===
  Bronze rows:         {bronze_count:,}
  Silver valid:        {valid_count:,}
  Silver quarantined:  {bronze_count - valid_count:,}
  Gold revenue:        {gold_row['total_revenue']:,.0f} RUB
  Gold orders:         {gold_row['total_orders']:,}
  Gold AOV:            {gold_row['avg_order_value']:,.0f} RUB
""")


if __name__ == "__main__":
    spark = (
        SparkSession.builder
        .appName("DailyOrdersPipeline")
        .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
        .getOrCreate()
    )

    batch_date = sys.argv[1] if len(sys.argv) > 1 else str(datetime.today().date())
    run_pipeline(spark, batch_date)

12.4 Observability: метаданные качества для мониторинга

Все результаты Quality Gates записываются в единую таблицу метаданных - это даёт полную картину здоровья платформы:

from dataclasses import dataclass, asdict
from datetime import datetime

@dataclass
class QualityRunRecord:
    """Одна запись результата Quality Gate."""
    run_date: str
    pipeline: str
    stage: str
    check_name: str
    check_type: str    # row_count | null_check | schema | business_rule | drift
    passed: bool
    metric_value: float
    threshold_value: float
    row_count: int
    severity: str
    recorded_at: str = None

    def __post_init__(self):
        if self.recorded_at is None:
            self.recorded_at = datetime.utcnow().isoformat()


def write_quality_metrics(
    spark: SparkSession,
    records: list,
    metrics_table: str = "metadata.quality_runs",
) -> None:
    """
    Записываем результаты всех проверок в единую метрики-таблицу.
    Используется для построения Quality Dashboard в BI-инструменте.
    """
    rows = [asdict(r) for r in records]
    metrics_df = spark.createDataFrame(rows)
    (
        metrics_df
        .write
        .mode("append")
        .format("delta")
        .partitionBy("run_date", "pipeline")
        .saveAsTable(metrics_table)
    )

12.5 Итоговая диаграмма полного pipeline


Итоги

Зрелая data platform строится вокруг идеи: данные с известным качеством лучше данных с неизвестным качеством, даже если последних больше. Quality Gates - инструмент превращения неизвестного в известное.

Single-Pass Aggregation - все метрики (null counts, row count, statistical summaries) считаются за один скан через один agg(). Отдельный .count() на каждую колонку - антипаттерн, порождающий N Spark Jobs вместо одного. На датасете 500GB разница между 20 отдельными count и single-pass agg составляет 10-20x по времени выполнения.

Разделение Bronze / Silver / Gold по строгости - Bronze принимает «всё как есть», только технические проверки. Silver выполняет семантическую валидацию с quarantine. Gold - финальные бизнес-барьеры с Fail-Fast. Нарушение этого разделения (строгие бизнес-правила на Bronze) приводит к потере сырых данных и невозможности расследовать upstream-проблемы.

Quarantine не замена Fail-Fast - изоляция плохих строк осмысленна, только если «плохих» меньше 20-50%. Если большинство данных повреждено, quarantine бесполезен - нужен Fail-Fast с явным сигналом о системной проблеме в upstream.

Delta Constraints как последний рубеж - CHECK в Delta останавливает запись на уровне хранилища. Это защита от «прорыва» некорректных данных через все логические барьеры. Но для quarantine они не подходят - constraints блокируют всю транзакцию целиком.

Observability всегда - результаты каждого gate пишутся в metadata-таблицу. Без этого невозможно строить дашборды здоровья платформы и отвечать на вопрос «когда впервые начались проблемы с качеством?».

Избегайте Alert Fatigue - жёсткие hardcoded пороги без учёта сезонности и дня недели порождают ложные срабатывания. Команда начинает игнорировать алерты. Динамические baselines (средние за 7 дней, сравнение по дню недели) снижают шум и повышают доверие к системе мониторинга.