Learning Platform
Глоссарий Troubleshooting
Урок 17.03 · 32 мин
Средний
capstonemodel-contractsdbt-expectationscustom-generic-testsstore-failures

Capstone: tests, contracts, dbt-expectations

В этом уроке поднимаем data quality с уровня “tutorial” до уровня “production”. Реализуем enforced contracts на 3 ключевых marts, добавляем 5+ продвинутых тестов через dbt-expectations, custom generic test для бизнес-правила, и настраиваем store_failures для дебага.

К концу урока проект имеет:

  • 3 marts с enforced contracts (mart_revenue_daily, mart_users_360, mart_orders)
  • 1 mart с versions (mart_revenue_daily v1 + v2)
  • 10+ generic tests + 5+ dbt-expectations tests
  • 1 custom generic test для revenue consistency
  • store_failures на 3+ критичных тестах
ODCS: contracts как governance-стандарт

Часть 1: Model contracts

Зачем contracts

Model contract — это формальное заявление: “эта модель имеет такие колонки, таких типов, с такими constraints”. Контракт enforced — dbt валидирует совпадение во время build. Если query вернул другую структуру (другая колонка, другой тип) — build падает.

Это критично для public marts, которые потребляются BI и ML-командами. Без контракта изменение dbt-модели может silent’но сломать downstream dashboard.

mart_revenue_daily: enforced contract

# models/marts/core/_models.yml
models:
  - name: mart_revenue_daily
    description: "Daily revenue aggregated by country. Updated daily, retains 3 years history."
    config:
      contract:
        enforced: true
    columns:
      - name: order_date
        description: "Date of orders (UTC)"
        data_type: DATE
        constraints:
          - type: not_null
        tests:
          - not_null

      - name: shipping_address_country
        description: "ISO 3166-1 alpha-2 country code"
        data_type: VARCHAR
        constraints:
          - type: not_null
        tests:
          - not_null
          - accepted_values:
              values: ['US', 'GB', 'DE', 'FR', 'JP', 'CA', 'AU', 'BR', 'IN', 'NL']

      - name: order_count
        description: "Number of orders for this day/country combo"
        data_type: BIGINT
        constraints:
          - type: not_null

      - name: unique_customers
        description: "Distinct user_id count"
        data_type: BIGINT
        constraints:
          - type: not_null

      - name: total_revenue_usd
        description: "Sum of order_total_usd, USD with 2 decimal precision"
        data_type: DECIMAL(18, 2)
        constraints:
          - type: not_null

      - name: avg_order_value_usd
        description: "Average order value (total_revenue / order_count)"
        data_type: DECIMAL(18, 2)

Что enforced

При dbt run --select mart_revenue_daily dbt сначала валидирует, что:

  1. Колонки совпадают по именам и порядку с YAML.
  2. Типы совпадают (DECIMAL(18, 2) vs DECIMAL(10, 2) — fail).
  3. not_null constraints — если есть constraints: [type: not_null] на колонке, dbt валидирует, что SELECT не возвращает NULL.

Если несовпадение — dbt run упадёт с понятной ошибкой типа “expected DECIMAL(18, 2), got DECIMAL(18, 4) on column total_revenue_usd”.

WARNING

DuckDB constraints — caveats. dbt-duckdb 1.10 поддерживает enforced contract по типам и not_null. Но: (1) Compound constraints (primary_key, foreign_key, check) — metadata-only, не enforced на data. (2) DuckDB-специфичные типы (HUGEINT, UUID) — не все validated. В production на Snowflake / BigQuery contract validation глубже.

mart_users_360: контракт + check constraint

  - name: mart_users_360
    description: "Customer 360 view: lifetime stats, segments, CLV"
    config:
      contract:
        enforced: true
    columns:
      - name: user_id
        data_type: BIGINT
        constraints:
          - type: not_null
          - type: primary_key  # metadata-only на DuckDB
        tests:
          - unique

      - name: user_email
        data_type: VARCHAR
        constraints:
          - type: not_null
        tests:
          - dbt_utils.expression_is_true:
              expression: "regexp_matches(user_email, '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$')"

      - name: lifetime_value_usd
        description: "Sum of all order_total for this user, excluding cancelled/fraudulent"
        data_type: DECIMAL(18, 2)
        constraints:
          - type: not_null
          - type: check
            expression: "lifetime_value_usd >= 0"

      - name: total_orders
        data_type: BIGINT
        constraints:
          - type: not_null
          - type: check
            expression: "total_orders >= 0"

      - name: customer_segment
        data_type: VARCHAR
        constraints:
          - type: not_null
        tests:
          - accepted_values:
              values: ['new', 'returning', 'vip', 'churned']

      - name: first_order_date
        data_type: DATE

      - name: last_order_date
        data_type: DATE

check constraints — на DuckDB metadata-only, но в YAML их указываем — это документация и подготовка к миграции на Snowflake / BigQuery (где они enforced).

mart_orders: contract + версионирование

  - name: mart_orders
    description: "Fact table: enriched orders with user and product info"
    latest_version: 1
    config:
      contract:
        enforced: true
    versions:
      - v: 1
    columns:
      - name: order_id
        data_type: VARCHAR
        constraints:
          - type: not_null
      - name: user_id
        data_type: BIGINT
        constraints:
          - type: not_null
      - name: user_email
        data_type: VARCHAR
      - name: first_order_date
        data_type: DATE
      - name: order_status
        data_type: VARCHAR
        constraints:
          - type: not_null
      - name: order_total_usd
        data_type: DECIMAL(18, 2)
        constraints:
          - type: not_null
      - name: order_items_count
        data_type: INTEGER
      - name: shipping_address_country
        data_type: VARCHAR
      - name: order_created_at
        data_type: TIMESTAMP
      - name: order_updated_at
        data_type: TIMESTAMP
      - name: dbt_loaded_at
        data_type: TIMESTAMP

latest_version: 1 — это объявление, что текущая default-версия — v1. Когда добавим v2 — мы изменим latest_version: 2, а v1 пометим deprecation_date.


Часть 2: Version-эволюция mart_revenue_daily

Сценарий: добавление новой колонки

Допустим, прошёл квартал. Marketing-команда просит добавить repeat_order_count (заказы от повторных покупателей) в mart_revenue_daily. Это breaking change — структура контракта меняется.

Production-подход — создать v2 без ломки v1.

Реализация v2

Создаём mart_revenue_daily_v2.sql:

-- models/marts/core/mart_revenue_daily_v2.sql
{{
  config(
    materialized='incremental',
    incremental_strategy='delete+insert',
    unique_key=['order_date', 'shipping_address_country'],
    on_schema_change='append_new_columns'
  )
}}

WITH base AS (
  SELECT * FROM {{ ref('mart_orders') }}
  {% if is_incremental() %}
    WHERE order_created_at::DATE >= (CURRENT_DATE - INTERVAL '7 days')
  {% endif %}
),

with_repeat_flag AS (
  SELECT
    *,
    CASE
      WHEN order_created_at::DATE > first_order_date::DATE
      THEN 1 ELSE 0
    END AS is_repeat_order
  FROM base
)

SELECT
  order_created_at::DATE AS order_date,
  shipping_address_country,
  COUNT(DISTINCT order_id) AS order_count,
  COUNT(DISTINCT user_id) AS unique_customers,
  SUM(is_repeat_order) AS repeat_order_count,  -- new in v2
  SUM(order_total_usd) AS total_revenue_usd,
  AVG(order_total_usd) AS avg_order_value_usd
FROM with_repeat_flag
WHERE order_status NOT IN ('cancelled', 'fraudulent')
GROUP BY 1, 2

YAML с versions

  - name: mart_revenue_daily
    description: "..."
    latest_version: 2
    config:
      contract:
        enforced: true
    versions:
      - v: 1
        deprecation_date: '2026-09-01'
      - v: 2
    columns:
      # v1 columns (общие)
      - name: order_date
        data_type: DATE
        constraints: [{type: not_null}]
      - name: shipping_address_country
        data_type: VARCHAR
        constraints: [{type: not_null}]
      - name: order_count
        data_type: BIGINT
        constraints: [{type: not_null}]
      - name: unique_customers
        data_type: BIGINT
        constraints: [{type: not_null}]
      - name: total_revenue_usd
        data_type: DECIMAL(18, 2)
        constraints: [{type: not_null}]
      - name: avg_order_value_usd
        data_type: DECIMAL(18, 2)
      # v2-only column
      - name: repeat_order_count
        data_type: BIGINT
        constraints: [{type: not_null}]
        config:
          v: [2]  # доступно только в v2

Downstream consumption

Consumer проекты теперь могут писать:

  • {{ ref('mart_revenue_daily') }} — резолвится в latest_version (v2)
  • {{ ref('mart_revenue_daily', v=1) }} — явно v1
  • {{ ref('mart_revenue_daily', v=2) }} — явно v2

После deprecation_date: '2026-09-01':

  • v1 ещё работает (если physically существует)
  • dbt выдаёт warning при ref(’…’, v=1)
  • После удаления v1 ref валидится в latest

Это API-versioning для данных. Marketing-команда может перейти на v2 в свободном темпе, не ломая существующих consumers.


Часть 3: dbt-expectations tests

Установка

# packages.yml
packages:
  - package: dbt-labs/dbt_utils
    version: 1.3.0
  - package: calogica/dbt_expectations
    version: 0.10.4
dbt deps

Применение в проекте

# models/marts/core/_models.yml — добавляем тесты к mart_revenue_daily
  - name: mart_revenue_daily
    # ... contract ...
    tests:
      - dbt_expectations.expect_table_row_count_to_be_between:
          min_value: 30
          max_value: 365 * 30  # max 30 стран × 365 дней
      - dbt_expectations.expect_table_aggregation_to_equal_other_table:
          expression: "SUM(total_revenue_usd)"
          compare_model: ref('mart_orders')
          compare_expression: "SUM(order_total_usd) FILTER (WHERE order_status NOT IN ('cancelled', 'fraudulent'))"
          tolerance: 0.01

    columns:
      - name: total_revenue_usd
        # ... existing ...
        tests:
          - dbt_expectations.expect_column_values_to_be_between:
              min_value: 0
              max_value: 10000000  # sanity check: no day > $10M
          - dbt_expectations.expect_column_value_lengths_to_be_between:
              min_value: 0
              max_value: 20

      - name: order_count
        tests:
          - dbt_expectations.expect_column_values_to_be_between:
              min_value: 0
              max_value: 100000

      - name: unique_customers
        tests:
          - dbt_expectations.expect_column_pair_values_A_to_be_smaller_than_B:
              column_A: unique_customers
              column_B: order_count
              or_equal: true  # customers <= orders (один может купить несколько)

Разбор тестов

expect_table_row_count_to_be_between — таблица должна иметь от 30 до 365×30 строк. Защищает от случайной пустой таблицы (например, при бажном WHERE).

expect_table_aggregation_to_equal_other_table — sanity check: SUM(revenue) в mart_revenue_daily должен равняться SUM(revenue) в mart_orders (с тем же фильтром). Если расхождение больше 0.01 — что-то сломано в агрегации.

expect_column_values_to_be_between — value range check. Защищает от outliers (например, баг записал $10B в одну строку).

expect_column_pair_values_A_to_be_smaller_than_B — relationship между колонками. Customers ≤ orders — это бизнес-инвариант.

Эти тесты — defensive layer. Стандартные not_null/unique ловят grobye errors. dbt-expectations ловят subtler problems: выбросы, нарушенные инварианты, расхождения между моделями.


Часть 4: Custom generic test

Когда стандартных тестов не хватает, пишем custom generic test.

Use case: revenue consistency check

Бизнес-правило: mart_revenue_daily.total_revenue_usd за день должен равняться сумме mart_orders.order_total_usd за тот же день (с фильтром на success). Стандартный тест не покрывает — нужен custom.

-- tests/generic/test_revenue_consistency.sql
{% test revenue_consistency(model, revenue_column, source_model, source_amount_column, source_date_column, success_statuses=['paid', 'shipped', 'delivered']) %}

WITH agg_target AS (
  SELECT
    order_date,
    SUM({{ revenue_column }}) AS target_total
  FROM {{ model }}
  GROUP BY 1
),

agg_source AS (
  SELECT
    {{ source_date_column }}::DATE AS order_date,
    SUM({{ source_amount_column }}) AS source_total
  FROM {{ source_model }}
  WHERE order_status IN (
    {% for status in success_statuses %}
      '{{ status }}'{% if not loop.last %},{% endif %}
    {% endfor %}
  )
  GROUP BY 1
),

joined AS (
  SELECT
    t.order_date,
    t.target_total,
    s.source_total,
    ABS(t.target_total - s.source_total) AS diff
  FROM agg_target t
  LEFT JOIN agg_source s USING (order_date)
)

SELECT *
FROM joined
WHERE diff > 0.01  -- толерантность 1 цент

{% endtest %}

Применение

  - name: mart_revenue_daily
    tests:
      - revenue_consistency:
          revenue_column: total_revenue_usd
          source_model: ref('mart_orders')
          source_amount_column: order_total_usd
          source_date_column: order_created_at

Тест запустится dbt test --select mart_revenue_daily, и упадёт, если найдёт хотя бы 1 день с расхождением > 1 cent.

Custom generic tests — это extension dbt для domain-specific invariants. Каждая команда обычно имеет 5-15 таких тестов на критичные бизнес-правила.


Часть 5: store_failures для дебага

Зачем

Когда тест падает, dbt по default’у показывает только COUNT failures. Чтобы дебажить — нужно знать, какие именно записи failed.

store_failures: true сохраняет failing rows в отдельную таблицу для дебага.

Конфигурация global

# dbt_project.yml
tests:
  +store_failures: true
  +store_failures_as: table  # или 'view'
  +schema: dbt_test_failures  # отдельная схема для failures

Конфигурация per-test

  - name: mart_revenue_daily
    tests:
      - dbt_expectations.expect_table_aggregation_to_equal_other_table:
          # ...
          config:
            store_failures: true
            severity: error
      - revenue_consistency:
          # ...
          config:
            store_failures: true
            store_failures_as: table  # перезаписывать на каждый run

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

После test fail:

# Dbt сохранил failures
dbt test --select mart_revenue_daily

# Проверяем в warehouse
SELECT * FROM dbt_test_failures.expect_table_aggregation_to_equal_other_table_mart_revenue_daily;

Видим конкретные записи / дни с расхождением. Дебагим.

TIP

store_failures cleanup. Failure tables создаются и не удаляются автоматически. Через год у вас в schema 200+ failure-таблиц. Решения: (1) Отдельная схема dbt_test_failures (легко чистить целиком). (2) dbt_project.yml: on-run-end: "{{ dbt_audit.cleanup_test_audit() }}" — кастомный macro для cleanup’а. (3) Использовать store_failures_as: view — view пересоздаётся каждый раз, не накапливается.


Часть 6: severity и тесты как контракты

  - name: mart_orders
    columns:
      - name: order_id
        tests:
          - not_null:
              severity: error  # default, но явно для документации
          - unique:
              severity: error

      - name: order_total_usd
        tests:
          - dbt_expectations.expect_column_values_to_be_between:
              min_value: 0
              max_value: 100000
              severity: error  # outliers — критично
          - dbt_expectations.expect_column_quantile_values_to_be_between:
              quantile: 0.99
              min_value: 50
              max_value: 5000
              severity: warn  # 99% percentile — soft check, warn а не error

Принцип: error для критичных constraints, warn для quality checks. Error падает CI, warn — лог + Explorer warning. Срез: ~30% error, ~70% warn для большинства проектов.


Проверка после реализации

dbt deps
dbt build --full-refresh  # все contracts валидируются при run
dbt test  # все тесты, включая dbt-expectations и custom

Ожидаемый результат:

  • 3 marts с contracts — validation pass
  • mart_revenue_daily v1 и v2 — оба работают, ref’ятся независимо
  • ~30+ tests pass (если данные согласованы)
  • 1-2 warning’а допустимы (это “soft checks”)

Что middle-инженер должен унести

  1. Знать, когда использовать contracts: критично для public marts, потребляемых BI/ML.
  2. Понимать caveats DuckDB-constraints (compound не enforced).
  3. Уметь делать version evolution: v1 -> v2 без ломки consumers.
  4. Применять dbt-expectations для quality checks выше стандартных not_null/unique.
  5. Писать custom generic tests для domain-specific invariants.
  6. Использовать store_failures для дебага failing tests.
  7. Распределять severity: error для критичных, warn для quality.

Проверка знанийKnowledge check
Запустили 'dbt test' — упал тест 'expect_table_aggregation_to_equal_other_table' на mart_revenue_daily. SUM(revenue) в mart_revenue_daily не равен SUM в mart_orders. Как с помощью store_failures найти причину расхождения и какой подход к дебагу?
ОтветAnswer
Подход к дебагу через store_failures: (1) Открыть failure table: SELECT * FROM dbt_test_failures.expect_table_aggregation_to_equal_other_table_mart_revenue_daily. Эта таблица содержит данные, которые тест посчитал failures — обычно агрегаты или строки. (2) Изучить структуру: для expect_table_aggregation вероятно одна строка с двумя значениями (target_sum и source_sum) и diff. (3) Если разница большая (тысячи долларов) — скорее всего систематическая ошибка: разные фильтры в моделях, разные определения "success status", округление timestamp в date. (4) Если маленькая (центы) — могут быть floating-point ошибки при aggregation. (5) Сделать ad-hoc query: посмотреть per-day breakdown, найти день с самым большим diff: SELECT order_date, SUM(...) FROM mart_revenue_daily GROUP BY 1 UNION ALL SELECT date(order_created_at), SUM(...) FROM mart_orders WHERE status IN (...) GROUP BY 1. Joined view покажет какие дни не совпадают. (6) Type causes: вероятные находки — (a) filter mismatch (mart_revenue_daily исключает 'fraudulent', mart_orders нет); (b) timezone issue (order_created_at в UTC vs local); (c) поздно прилетевшие orders в одной модели обновились, в другой нет; (d) decimal precision (mart_revenue_daily DECIMAL(18,2), source DECIMAL(18,4) — округление). После находки — fix в SQL модели, повторить test. Custom generic test revenue_consistency можно extend'нуть, чтобы возвращать per-day differences вместо summary — это даст более granular failures.

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 6. Зачем enforced model contracts критичны для public marts (mart_revenue_daily, mart_users_360, mart_orders), потребляемых BI и ML командами? Какие 3 риска contracts elиminiruyut?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4