ServicesÀ proposNotesContact Me contacter →
EN FR
Note

Matérialisation de table sans interruption de service dans dbt

Une matérialisation dbt personnalisée qui construit sous un nom temporaire, valide le nombre de lignes, puis permute via un renommage — en maintenant l'ancienne table interrogeable jusqu'à ce que la nouvelle soit confirmée prête.

Planté
dbtbigquerydata engineeringdata quality

La matérialisation table standard de dbt construit une table temporaire, supprime la table existante, puis renomme la temporaire à sa place. Cela fonctionne pour la plupart des modèles. Cela se détériore pour les tables critiques en production de deux façons spécifiques.

L’interruption de disponibilité. Il existe une brève fenêtre entre DROP et RENAME où la table n’existe pas. Si une requête de dashboard ou un export planifié tombe dans cette fenêtre, elle échoue avec une erreur « table introuvable ». Pour les modèles internes, personne ne le remarque. Pour la table qui alimente un dashboard client, c’est un ticket de support.

L’absence de validation. Il n’y a aucune vérification avant le remplacement. Si le SQL du modèle retourne zéro ligne parce qu’une table source est vide ou qu’un filtre de date est incorrect, la matérialisation standard remplace une table d’un million de lignes par une table vide. On ne le découvre pas avant que quelqu’un remarque des données manquantes en aval.

Une matérialisation sans interruption résout les deux problèmes : elle construit sous un nom temporaire, valide le résultat, puis permute via un renommage. L’ancienne table reste interrogeable jusqu’à ce que la nouvelle soit confirmée prête.

Le pattern

La structure en six étapes reste la même, mais l’étape SQL principale construit dans une relation temporaire au lieu de la cible, et une étape de validation s’intercale entre la construction et la permutation.

Créer macros/materializations/zero_downtime_table.sql :

{%- materialization zero_downtime_table, adapter='bigquery' -%}
{# Configuration #}
{% set min_row_count = config.get('min_row_count', 1) %}
{# Initialisation des relations #}
{% set target_relation = this.incorporate(type='table') %}
{% set existing_relation = load_cached_relation(this) %}
{% set temp_relation = make_temp_relation(target_relation) %}
{% set backup_relation = make_backup_relation(target_relation) %}
{# Gérer l'incompatibilité de type (la relation existante est une vue, pas une table) #}
{% if existing_relation is not none and existing_relation.type != 'table' %}
{% do adapter.drop_relation(existing_relation) %}
{% set existing_relation = none %}
{% endif %}
{{ run_hooks(pre_hooks) }}
{# Construire la nouvelle table sous un nom temporaire. L'ancienne reste interrogeable. #}
{% call statement('main') %}
{{ create_table_as(false, temp_relation, sql) }}
{% endcall %}
{# Valider avant la permutation #}
{% call statement('validate', fetch_result=True) %}
SELECT COUNT(*) AS row_count FROM {{ temp_relation }}
{% endcall %}
{% set row_count = load_result('validate')['data'][0][0] %}
{% if row_count < min_row_count %}
{% do adapter.drop_relation(temp_relation) %}
{{ exceptions.raise_compiler_error(
"Validation failed: " ~ row_count ~ " rows, expected at least "
~ min_row_count ~ ". Old table preserved."
) }}
{% endif %}
{# Permutation : renommer l'existante en backup, puis la temporaire en cible #}
{% if existing_relation is not none %}
{% do adapter.rename_relation(existing_relation, backup_relation) %}
{% endif %}
{% do adapter.rename_relation(temp_relation, target_relation) %}
{# Supprimer le backup après une permutation réussie #}
{% if existing_relation is not none %}
{% do adapter.drop_relation(backup_relation) %}
{% endif %}
{{ run_hooks(post_hooks) }}
{% set grant_config = config.get('grants') %}
{% do apply_grants(target_relation, grant_config) %}
{% do persist_docs(target_relation, model) %}
{% do adapter.commit() %}
{{ return({'relations': [target_relation]}) }}
{%- endmaterialization -%}

Comment la permutation fonctionne

La séquence est importante. L’ancienne table est renommée en backup avant que la nouvelle prenne sa place. Si la construction ou la validation échoue, l’ancienne table reste intacte — l’erreur se déclenche avant tout renommage. Le backup n’est supprimé qu’après le succès de la permutation.

La fenêtre où ni l’ancienne ni la nouvelle table n’existe sous le nom cible correspond au temps d’exécution de deux opérations de métadonnées BigQuery : renommer l’ancienne en backup, renommer la temporaire en cible. C’est typiquement moins d’une seconde, contre une interruption potentiellement longue de plusieurs minutes lors d’une reconstruction de table standard.

BigQuery ne prend pas en charge les permutations atomiques de tables comme le ALTER TABLE ... SWAP WITH de Snowflake, donc cette fenêtre ne peut pas être entièrement éliminée. Mais elle est suffisamment courte pour que la plupart des requêtes concurrentes ne la rencontrent pas. Pour des exigences de zéro interruption stricte, une approche différente serait nécessaire (des vues comme couches d’abstraction, par exemple), mais pour la pratique ce pattern élimine le problème.

Utilisation dans un modèle

{{ config(
materialized='zero_downtime_table',
min_row_count=1000
) }}
SELECT
user_id,
email,
account_status,
created_at
FROM {{ ref('int__users_joined') }}
WHERE account_status != 'deleted'

Si le modèle retourne moins de 1000 lignes, dbt lève une erreur et l’ancienne table reste en place. Les logs indiquent exactement ce qui s’est passé : « Validation failed: 42 rows, expected at least 1000. Old table preserved. » Pas de devinette.

Comparaison avec le build précédent

Un seuil statique sur le nombre de lignes détecte les tables vides, mais une vérification relative détecte des problèmes plus subtils. La nouvelle table peut être comparée à la table existante pour signaler des baisses inattendues de volume :

{# Ajouter ceci après la vérification min_row_count #}
{% set max_drop_pct = config.get('max_row_count_drop_pct', 50) %}
{% if existing_relation is not none and max_drop_pct is not none %}
{% call statement('old_count', fetch_result=True) %}
SELECT COUNT(*) FROM {{ existing_relation }}
{% endcall %}
{% set old_count = load_result('old_count')['data'][0][0] %}
{% if old_count > 0 %}
{% set drop_pct = ((old_count - row_count) * 100 / old_count) | int %}
{% if drop_pct > max_drop_pct %}
{% do adapter.drop_relation(temp_relation) %}
{{ exceptions.raise_compiler_error(
"Row count dropped " ~ drop_pct ~ "% (" ~ old_count ~ " to " ~ row_count
~ "), threshold is " ~ max_drop_pct ~ "%. Old table preserved."
) }}
{% endif %}
{% endif %}
{% endif %}

Cela détecte le scénario où une table source se vide partiellement ou où un filtre retire trop de données. Le modèle échoue de façon contrôlée plutôt que de pousser des données incomplètes en aval.

Les deux couches de validation se complètent :

  • Minimum absolu (min_row_count) : détecte les pannes totales — tables sources vides, jointures cassées qui produisent zéro ligne, filtres mal configurés.
  • Baisse relative (max_row_count_drop_pct) : détecte les pannes partielles — une source qui a perdu la moitié de ses données, une nouvelle clause WHERE trop agressive, une partition manquante en amont.

Pour les tables avec des volumes naturellement volatils (données saisonnières, trafic piloté par les campagnes), définir max_row_count_drop_pct à none dans la config du modèle pour désactiver la vérification relative tout en conservant le minimum absolu.

Quand ce pattern est adapté

Utiliser la matérialisation sans interruption quand :

  • La table alimente des dashboards client ou des consommateurs externes pour lesquels les erreurs « table introuvable » créent des tickets de support
  • Les données sont suffisamment critiques pour qu’une table vide ou dramatiquement réduite causerait de vrais dommages
  • Il existe une attente raisonnable sur le nombre de lignes de la table

Pour les modèles de développement internes ou les tables qui ne sont interrogées que par des modèles dbt en aval lors d’une exécution (pas de requêtes concurrentes), la matérialisation table standard convient. L’impact coût de la requête COUNT supplémentaire est négligeable par rapport au coût de reconstruction de la table elle-même.

C’est la plus simple des deux matérialisations personnalisées courantes et un bon point de départ pour apprendre la structure. Une fois à l’aise avec le pattern permutation-et-validation, le pattern de table sécurisée ajoute plus d’opérations post-build en suivant les mêmes principes.