Le contrat de la leçon précédente garantit qu'aucun document n'est perdu : tout ce qui échoue revient. Il garantit aussi l'inverse de ce que veut la comptable : un message peut revenir alors qu'il a déjà été traité. Un délai d'acquittement dépassé, une instance arrêtée entre l'écriture BigQuery et la réponse HTTP, et la facture apparaît deux fois, avec deux appels Gemini payés. Le worker doit donc être idempotent : traiter deux fois le même message produit le même état qu'une seule fois.
Le mécanisme
Pub/Sub attribue à chaque message publié un messageId unique sur le sujet, et le conserve à chaque relivraison. C'est la clé de déduplication de ce projet. Le worker fait deux vérifications, qui n'ont pas le même rôle :
seen(message_id): une requêteSELECT 1 … LIMIT 1avant l'appel Gemini. Elle économise l'appel dans le cas courant, une relivraison quelques secondes après le succès.record(): unMERGE … WHEN NOT MATCHED THEN INSERT. Il garantit l'unicité, y compris quand deux livraisons du même message arrivent en même temps sur deux instances et passent toutes les deux la première vérification.
flowchart TD
M["Message reçu<br/>messageId = 2220…603"] --> S{"seen() ?"}
S -- "oui" --> D["200 duplicate<br/>aucun appel Gemini"]
S -- "non" --> C{"document valide ?"}
C -- "non" --> R["MERGE status=rejected"]
C -- "oui" --> G["Gemini"] --> O["MERGE status=ok"]
O --> A{"ligne insérée ?"}
R --> A
A -- "1 ligne" --> OK["200 ok / rejected"]
A -- "0 ligne : l'autre livraison a gagné" --> D
Pourquoi ne pas laisser Pub/Sub dédupliquer ? Sa livraison « exactement une fois » n'existe que pour les abonnements pull, et seulement dans une région ; un abonnement push, comme celui-ci, reste « au moins une fois ». Et même en pull, elle ne couvre pas la panne qui survient entre votre écriture et l'acquittement.
Quelle clé pour quel doublon
L'ID de message protège contre les relivraisons de Pub/Sub. Il ne protège pas contre deux publications du même événement : si deux notifications identiques existent sur le bucket, chaque dépôt produit deux messages, avec deux ID différents, et deux lignes ok.
| Origine du doublon | Même messageId ? | Protection |
|---|---|---|
| Relivraison après délai d'acquittement dépassé | oui | seen() + MERGE |
| Instance arrêtée après l'écriture, avant la réponse | oui | MERGE (0 ligne insérée → duplicate) |
| Deux notifications sur le bucket | non | cloud-up.sh refuse d'en créer une seconde |
| Le même fichier redéposé par l'utilisateur | non (nouvelle génération) | voulu : c'est une nouvelle version |
La lecture par génération complète le dispositif : chaque écriture d'un objet Cloud Storage reçoit un numéro de génération, transmis dans l'événement. Le worker lit blob(name, generation=…), donc la version qui a déclenché l'événement, même si l'objet a été remplacé entre-temps.
La mise en œuvre
def handle(event: Event, store: Store, reader: Reader, analyze: Analyzer) -> str:
if store.seen(event.message_id):
return "duplicate" # redélivrance : on n'appelle pas Gemini une seconde fois
try:
check(event)
data = reader.read(event.bucket, event.name, event.generation)
except Rejected as exc:
return "rejected" if store.record(event, "rejected", reason=str(exc)) else "duplicate"
analyse, usage = analyze(data, event.content_type)
# Le seen() ci-dessus économise un appel Gemini ; c'est record() qui garantit l'unicité
# (deux livraisons simultanées passent toutes les deux le seen()).
return "ok" if store.record(event, "ok", analyse=analyse, usage=usage) else "duplicate"Le MERGE côté BigQuery, paramétré (jamais de valeur concaténée dans le SQL) et plafonné en octets facturés :
sql = (
f"MERGE `{self.table}` T USING (SELECT @message_id AS message_id) S "
f"ON T.message_id = S.message_id "
f"WHEN NOT MATCHED THEN INSERT ({cols}) VALUES ({', '.join('@' + c for c in row)})"
)
...
job = self.client.query(sql, job_config=cfg)
job.result()
return (job.num_dml_affected_rows or 0) == 1La table est regroupée (clustering) par message_id : la recherche ne lit que les blocs qui contiennent cet ID. Le test de la course simule deux livraisons qui ne se voient pas :
def test_livraisons_simultanees_un_seul_enregistrement():
store, analyzer = FakeStore(), FakeAnalyzer()
e = parse_envelope(envelope())
store.seen = lambda _id: False # simule la course : aucune ne voit l'autre
assert sorted([handle(e, store, FakeReader(), analyzer), handle(e, store, FakeReader(), analyzer)]) == ["duplicate", "ok"]
assert len(store.rows) == 1Résultat obtenu (2026-10-16)
make test passe (16 tests). Sur Google Cloud, make e2e renvoie au worker une enveloppe portant le messageId d'un document déjà analysé, puis compte les lignes :
15:17:58 rejeu du message 22201064101151603 : {'http': 200, 'status': 'duplicate'}
15:23:20 OK 10 lignes, une par document
15:23:20 OK aucun message_id en double
15:23:20 OK rejeu acquitté comme doublonCe qui casse en production
- Notification créée deux fois. Un
gcloud storage buckets notifications createrelancé à la main, ou un script non idempotent : chaque document produit deux lignesoket deux appels Gemini, sans un seulduplicatedans les journaux. Diagnostic :gcloud storage buckets notifications list gs://…. Parade : le test d'existence danscloud-up.sh, et une alerte surCOUNT(*) > 1 GROUP BY object_uri. - Conflits de DML concurrents. BigQuery limite les instructions qui modifient une même table en parallèle ; au-delà, elles attendent en file, puis échouent. Symptôme : des 500 avec
concurrent updatesous forte charge. Ici l'erreur remonte en 500 et Pub/Sub réessaie, ce qui suffit à quelques centaines de documents par jour. Au-delà de quelques documents par seconde, écrivez par l'API Storage Write en ajout seul et dédupliquez à la lecture (QUALIFY ROW_NUMBER() OVER (PARTITION BY message_id ORDER BY processed_at) = 1). - Déduplication qui coûte plus que le traitement. Sans regroupement par
message_id, chaqueseen()lirait toute la table ; à 2 Go de table, 600 documents par jour, cela fait plus de 1 To lu par jour. Parade : clustering, etmaximum_bytes_billed(100 Mo ici) qui transforme une dérive en erreur visible. - Écrire avant d'analyser. Insérer une ligne « en cours » puis la compléter crée un état intermédiaire qu'une panne fige : la ligne existe,
seen()répond oui, le document n'est jamais analysé. Parade : une seule écriture, à la fin.
La règle de décision
Une seule écriture, conditionnelle, à la fin du traitement ; une vérification préalable seulement si elle économise un appel coûteux. Choisissez la clé selon le doublon à absorber : messageId pour les relivraisons, bucket/objet#génération si plusieurs publications du même événement sont possibles. Surveillez le taux de duplicate : quelques pour mille est normal, quelques pour cent révèle un délai d'acquittement mal réglé.
Pratique
Une équipe veut ajouter un second abonnement push sur le même sujet pour alimenter un autre service, qui écrit dans la même table. Que se passe-t-il pour la déduplication ?
Le messageId est attribué à la publication, il est identique pour tous les abonnements. Avec la même table et la même clé, le premier service qui écrit gagne et l'autre reçoit duplicate. La bonne conception : une table (ou une colonne de clé) par consommateur. Les deux autres options décrivent un comportement de file de travail que Pub/Sub n'a pas : chaque abonnement reçoit tous les messages.
Un utilisateur redépose le même fichier 06-relance-impaye.txt une heure plus tard, après correction d'un montant. Combien de lignes contient la table pour ce nom de fichier ?
Un redépôt crée une nouvelle génération de l'objet et un nouvel OBJECT_FINALIZE, donc un nouveau message. C'est voulu : c'est une nouvelle version du document, et object_uri, qui contient la génération, les distingue. Le MERGE ne compare que message_id ; il n'écrase jamais.
Exercice. Modifiez la déduplication pour qu'elle résiste aussi à une notification doublée, sans perdre les redépôts volontaires. Quelle clé, et quels fichiers changent ?
Voir la solution
La clé devient object_uri (gs://bucket/objet#génération) : deux notifications du même dépôt portent la même génération, un redépôt en porte une nouvelle.
gcp.py:seen()et leMERGEcomparentobject_uriau lieu demessage_id;infra/cloud-up.sh:--clustering_fields=object_uri(le regroupement d'une table existante peut être changé avecbq update --clustering_fields) ;tests/test_pipeline.py: un test où deux enveloppes d'ID différents portent la même génération, et doivent donner["duplicate", "ok"].
Gardez message_id comme colonne : il sert à retrouver une livraison dans les journaux.
- J'explique pourquoi un abonnement push reste « au moins une fois ».
- Je sépare la vérification qui économise de l'écriture qui garantit.
- Je choisis la clé de déduplication selon l'origine des doublons.
- Je plafonne le coût de la déduplication (clustering,
maximum_bytes_billed).