Versioned cross-project models: v1 -> v2 migration
Когда public model в Mesh нужно изменить breaking способом (rename column, change data type, remove field) — это нельзя сделать в-лоб. Consumers, которые depend на текущую схему, ломаются. Решение — versioned models, где старая v1 и новая v2 сосуществуют, consumers migrate в своём темпе.
Этот урок — про практику version migration: setup, communication, monitoring, gotchas.
Migration pattern v1 -> v2 без поломки consumers (dbt II)Зачем versions
Без versions — Finance меняет column в fct_revenue. Marketing dashboard рушится через 5 минут. Это плохой experience.
С versions:
- Finance делает v2 с изменённой схемой.
- v1 остаётся работать (старая SQL, старая table).
- Marketing видит deprecation warning, мigrate когда удобно.
- Через 3 месяца Finance удаляет v1.
Это smooth migration vs hard breakage.
Setup versioned model
В dbt versioning происходит через schema.yml declarations + два physical SQL файла:
# finance_dbt/models/schema.yml
models:
- name: fct_revenue
access: public
latest_version: 2
config:
contract:
enforced: true
versions:
- v: 1
deprecation_date: '2026-12-31'
defined_in: fct_revenue_v1
columns:
- name: date
data_type: date
- name: revenue
data_type: decimal(10,2) # old precision
- v: 2
defined_in: fct_revenue_v2
columns:
- name: date
data_type: date
- name: revenue_usd # renamed from 'revenue'
data_type: decimal(18,2) # changed precision
- name: product_id # new column
data_type: string
Physical файлы:
-- models/fct_revenue_v1.sql
{{ config(materialized='table') }}
select
date,
revenue
from {{ ref('int_revenue') }}
-- models/fct_revenue_v2.sql
{{ config(materialized='table') }}
select
date,
revenue as revenue_usd,
product_id
from {{ ref('int_revenue_v2') }}
dbt build создаёт две физических таблицы:
analytics.finance.fct_revenue_v1analytics.finance.fct_revenue_v2
latest_version: 2 — означает, что ref('finance', 'fct_revenue') без явной version -> v2.
Consumer’s view: ref with v
В downstream:
-- Marketing: pin to v1
select * from {{ ref('finance', 'fct_revenue', v=1) }}
-- Product: use latest (v2)
select * from {{ ref('finance', 'fct_revenue') }}
-- Another: pin to v2 explicitly
select * from {{ ref('finance', 'fct_revenue', v=2) }}
ref(..., v=1) резолвится к fct_revenue_v1. ref(...) без version — к fct_revenue_v2 (latest_version).
Каждая consuming model wybira свою version. Marketing может stay on v1 пока migrate’ятся. Product мovesна v2 сразу.
Deprecation date
В schema.yml:
versions:
- v: 1
deprecation_date: '2026-12-31'
После deprecation_date:
- dbt при compile печатает warning для consumers, которые используют v1.
- Run всё ещё works (data в table остаётся).
- Это soft signal — нужно migrate.
[WARNING] Model 'fct_revenue' v=1 in 'finance' is deprecated.
Deprecation date: 2026-12-31 (passed 45 days ago).
Please migrate to v=2.
Через какое-то время (типично 3-6 месяцев после deprecation_date) — Finance удаляет v1:
- Удаляет
fct_revenue_v1.sqlfile. - Удаляет
v: 1declaration из schema.yml. - Удаляет physical table в warehouse.
После этого ref(‘finance’, ‘fct_revenue’, v=1) — compilation error.
Migration workflow: Producer’s side
Step-by-step для Finance team:
Step 1: Создать v2
-- models/fct_revenue_v2.sql
{{ config(materialized='table') }}
select
date,
revenue as revenue_usd,
product_id
from {{ ref('int_revenue') }}
Step 2: Update schema.yml
- name: fct_revenue
access: public
latest_version: 2 # changed from 1
versions:
- v: 1
deprecation_date: '2026-12-31' # дёрнули deprecation
defined_in: fct_revenue_v1
- v: 2
defined_in: fct_revenue_v2
Step 3: Build
dbt build --select fct_revenue
dbt build обе версии. Создаёт fct_revenue_v1 и fct_revenue_v2 физические таблицы.
Step 4: Communicate consumers
- Slack message в #data-platform-changes: “fct_revenue v2 available. v1 deprecation 2026-12-31.”
- Email список consumers (из manifest, см. ниже).
- Update docs в team wiki.
Step 5: Monitor adoption
Скрипт, парсящий cross-project manifests для usage analysis:
# scripts/version_usage.py
import json
from pathlib import Path
def find_v1_consumers(target_model='fct_revenue', target_version=1):
consumers = []
for manifest_file in Path('manifests/').glob('*/manifest.json'):
with open(manifest_file) as f:
manifest = json.load(f)
for node in manifest['nodes'].values():
compiled = node.get('compiled_code', '')
if f"fct_revenue_v{target_version}" in compiled:
consumers.append({
'project': manifest['metadata']['project_name'],
'model': node['name'],
})
return consumers
print(find_v1_consumers())
Это даёт list of consumers, ещё на v1. Можно target communication.
Step 6: Final removal
Когда usage v1 = 0:
- Remove
fct_revenue_v1.sql. - Remove
v: 1из schema.yml. - Drop
analytics.finance.fct_revenue_v1в warehouse. - Communicate: “v1 удалена”.
Migration workflow: Consumer’s side
Для Marketing team:
Step 1: Stay on v1 (initially)
После Finance релизит v2 — Marketing продолжает работать на v1:
select * from {{ ref('finance', 'fct_revenue', v=1) }}
Никаких изменений, всё работает.
Step 2: Plan migration
Marketing reviews v2 schema:
revenue->revenue_usd(rename, нужно update SELECT).- New column
product_id— optional, можно use или нет.
Step 3: Create migration PR
-- Before:
select
date,
revenue
from {{ ref('finance', 'fct_revenue', v=1) }}
-- After:
select
date,
revenue_usd as revenue, -- alias на старое имя для backward compat
product_id
from {{ ref('finance', 'fct_revenue', v=2) }}
Test, deploy, merge. Marketing now on v2.
Step 4: Monitor
После migrate — watch for issues. v2 имеет new column product_id — maybe data quality issues? Tests могут catch.
Что считается breaking change
Не все изменения нужны version bump. Some changes safe:
Non-breaking (additive):
- Adding new column. Old SELECT continues to work.
- Adding new optional column. Same.
- Adding new not-null column with default value.
Breaking:
- Renaming column.
- Removing column.
- Changing column data_type incompatible way (decimal -> text).
- Changing constraint (not_null -> nullable for query expecting not_null).
Subtle (sometimes breaking):
- Changing nullability (nullable -> not_null) — old data with nulls may break.
- Adding constraint (uniqueness) — may break if data has duplicates.
For additive changes — don’t bump version. Просто add column to existing v1 schema.yml.
For breaking — version bump.
Versions vs branching
Какой подход лучше — versions или git branching?
| Aspect | Versions (in dbt) | Git branching |
|---|---|---|
| Concurrent v1/v2 | Yes — two physical tables | No — only one in prod |
| Consumer choice | Each consumer picks v | All consumers stuck |
| Atomic releases | No (gradual migrate) | Yes (all at once) |
| Storage cost | 2x (v1 + v2 в warehouse) | 1x |
| Complexity | Medium | Low |
Versions = gradual migration, медленнее, но безопаснее. Branching = atomic, быстрее, но рискованнее (всё ломается синхронно).
В Mesh обычно используются versions для public API stability.
Subtle gotcha: shared upstream
Sometimes v1 и v2 разделяют upstream. Например:
-- fct_revenue_v1.sql
select date, revenue from {{ ref('int_revenue') }}
-- fct_revenue_v2.sql
select date, revenue as revenue_usd from {{ ref('int_revenue') }} -- тот же upstream
При build — int_revenue собирается один раз, две версии запрашивают его. Это OK.
Но если v2 требует другой upstream (например, новый int_revenue_v2):
-- fct_revenue_v2.sql
select date, revenue_usd, product_id from {{ ref('int_revenue_v2') }}
И int_revenue_v2 тоже public + versioned… быстро запутаешься. Best practice — сохранять versioning только на edges (public exposing), internal модели не versioning.
Failure modes
1. Breaking change без version
Finance меняет column type revenue: decimal(10,2) -> decimal(18,2) без version bump. With contract enforced:
Contract validation failed.
dbt build падает. Это good — Finance не может сломать contract silent.
Без contract — silent breakage downstream.
2. Consumer stuck on v1 после deprecation
Deprecation date passed. v1 ещё используется. Eventually Finance drops v1:
Compilation error: 'fct_revenue' v=1 not found.
Consumer build падает. Это late-fail.
Fix: monitor v1 usage перед drop, ensure all migrated.
3. v1 и v2 diverge silently
После split на v1 и v2 — bug fix landed в int_revenue (upstream обоих). Логика хорошая для v2, но breaks v1 semantically. v1 теперь возвращает чуть другие numbers.
Fix: keep v1 frozen — отдельные upstream pinned to v1’s original logic. Не shared upstream после version split.
4. Version drift в большом ecosystem
Finance имеет v1, v2, v3 параллельно. Marketing на v2. Product на v3. New team создаёт project — какой версии использовать? Default latest (v3), но maybe team should be на v2 for consistency с другими project.
Fix: documentation + recommendation. “New projects use latest_version. Existing projects stay until ready to migrate.”
5. v3 inhrabит deprecation v1
Finance имеет v1 (deprecating soon) and v2 (current). Sometime later adds v3. Now v1 + v2 + v3 in prod. Marketing on v1 still — Finance forgot to push migration. v1 deprecation hits, drop, Marketing breakage.
Fix: discipline. Don’t add v3 пока v1 customers gone (or until v1 fully removed).
Best practices
- Reserve versioning for breaking changes — additive changes don’t need version.
- Plan deprecation timeline upfront — when bump version, set deprecation date 6-12 months out.
- Monitor usage — script для tracking v1 consumers.
- Communicate proactively — Slack, email, docs.
- Don’t accumulate versions — keep max 2 versions in prod at a time.
- Document version differences — schema.yml description должна explain what changed.
- Test both versions — both v1 and v2 should have tests.
Real-world example timeline
E-commerce company, fct_orders public model:
Day 0: Add is_subscription flag (additive). Don’t version. Update v1 schema with new column.
Day 60: Need to rename total_usd -> gross_revenue_usd + add net_revenue_usd (breaking). Plan v2.
Day 70: Deploy v2. Communicate to 8 consumer teams. Set deprecation_date Day 270 (6 months).
Day 80-200: 6 of 8 teams migrate. Easy migrate, hours of work each.
Day 200: Slow movers — 2 teams still on v1. Schedule 1:1, help migrate.
Day 250: All on v2. Deprecation date approaching.
Day 270: Deprecation date passes. Compile warnings appear.
Day 280: No more usage of v1 (verified via manifest scan).
Day 300: Remove v1 file, schema.yml entry, drop table. Communicate.
Total cycle — about 6-10 months for full migration. This is normal pace for ecosystem with multiple consumers.
Резюме
- Versions — механизм для breaking changes на public моделях.
latest_version+versions— declarations в schema.yml.- Two physical files —
fct_revenue_v1.sql,fct_revenue_v2.sql. Two tables в warehouse. ref('project', 'model', v=N)— consumer pins to version.deprecation_date— soft signal, dbt warns в compile.- Migration: producer creates v2 + deprecation для v1, consumers migrate в темпе.
- Non-breaking changes — don’t version (additive columns).
- Communication critical — Slack, docs, manifest tracking.
- Avoid version accumulation — max 2 versions in prod.
- Failure modes: contract violation без bump, stuck consumers, version drift, shared upstream diverge.