Le contexte
Un job de chargement nocturne écrit chaque jour les commandes de la veille dans une table de faits, consommée ensuite par les dashboards revenue de l'équipe finance. Un matin, le dashboard affiche un chiffre d'affaires quasiment doublé sur les trois dernières semaines. Rien n'avait changé dans la logique métier. Le coupable : un retry Airflow, la nuit précédente, sur une tâche qui n'était pas idempotente.
Ce qui s'est passé
La tâche faisait un INSERT simple depuis la source vers la table de faits, sans aucune notion de "cette plage a déjà été chargée". Cette nuit-là :
- La tâche démarre, commence à écrire les lignes de commandes du jour.
- Une coupure réseau de quelques secondes entre le worker et l'entrepôt interrompt l'écriture — environ 60 % des lignes sont déjà commitées côté base.
- Airflow marque la tâche en échec et la relance automatiquement (retry configuré à 2 tentatives, comportement standard).
- Le retry repart de zéro et réinsère l'intégralité des commandes du jour, y compris les 60 % déjà présentes.
Le problème est resté invisible pendant trois semaines parce que le retry ne loggait rien d'anormal — de son point de vue, il avait juste fait son travail. C'est l'équipe finance qui a fini par remarquer l'écart, pas notre monitoring.
Pourquoi l'idempotence est plus dure qu'il n'y paraît
Le réflexe naturel est de vouloir garantir l'exécution "exactly-once" : chaque tâche s'exécute exactement une fois, jamais plus. En pratique, dans un système distribué avec un orchestrateur, des workers, un réseau et une base de données, exactly-once à l'exécution n'existe pas — les coupures réseau, les kills OOM, les redémarrages de scheduler et les retries manuels sont inévitables.
Ce qu'on peut réellement garantir, c'est at-least-once à l'exécution, combiné à des écritures idempotentes : la tâche peut être rejouée un nombre arbitraire de fois, le résultat final dans la base doit être identique à une exécution unique. C'est un changement de posture : on arrête d'essayer d'empêcher les retries (impossible) pour se concentrer sur le fait qu'ils ne doivent jamais avoir d'effet cumulatif.
Le vrai problème : des inserts append-only sans clé de dédup
Le pattern fautif, très commun parce qu'il est le plus simple à écrire :
@task
def load_orders(execution_date):
orders = extract_orders(execution_date)
write_to_warehouse(orders, mode="append") # ← chaque exécution ajoute, ne remplace jamaisN'importe quel retry, manuel ou automatique, duplique tout ce qui a déjà été écrit lors d'une tentative précédente, complète ou partielle.
Ce qu'on a changé
1. Remplacement de l'append par un swap de partition atomique. Chaque exécution écrit dans une partition isolée (une par jour), puis remplace la partition existante en une seule opération atomique plutôt que d'ajouter des lignes :
-- ClickHouse : on charge dans une table temporaire, puis on échange
-- la partition en une opération atomique — soit tout, soit rien.
INSERT INTO orders_staging SELECT * FROM input_orders WHERE order_date = '2026-08-09';
ALTER TABLE orders_fact
REPLACE PARTITION '2026-08-09' FROM orders_staging;Un retry, même partiel, ne fait que réécrire la même partition avec le même contenu — le résultat final ne dépend plus du nombre de tentatives.
2. Une clé de déduplication au niveau moteur, en filet de sécurité. Sur les tables où le swap de partition n'est pas pratique, on s'appuie sur ReplacingMergeTree avec une clé métier (order_id) et une colonne de version — les doublons finissent par être déduplicités lors des merges, et on peut forcer une déduplication immédiate avec FINAL pour les cas critiques.
3. Des tests d'idempotence en CI. Chaque nouvelle tâche de chargement doit passer un test qui l'exécute deux fois de suite sur un jeu de données fixe et vérifie que le résultat est strictement identique aux deux passages :
def test_load_orders_is_idempotent(warehouse):
load_orders("2026-01-01")
snapshot_1 = warehouse.query("select count(*) from orders_fact")
load_orders("2026-01-01") # même exécution, une deuxième fois
snapshot_2 = warehouse.query("select count(*) from orders_fact")
assert snapshot_1 == snapshot_2Résultat
Zéro incident de duplication depuis la mise en place de ce pattern, malgré plusieurs dizaines de retries légitimes (coupures réseau, timeouts) qui se seraient auparavant traduits par des données dupliquées silencieusement.
La règle qu'on applique maintenant
Concevoir chaque tâche comme si elle allait s'exécuter deux fois. Ce n'est pas une hypothèse pessimiste — dans un pipeline qui tourne tous les jours pendant des années, ça finit toujours par arriver. La question n'est pas "est-ce que ma tâche peut échouer et être relancée", mais "qu'est-ce qui se passe concrètement quand elle l'est".