learn.chetana.fr

Polling email et déduplication multi-réplica

15 min readAdvanced

La boucle de fond : increvable par construction

Toutes les 120 s, une tâche email-poll (lancée dans le lifespan) balaie les boîtes mail configurées — IMAP générique (imaplib en to_thread, il est sync) ou API OAuth (Gmail, Microsoft Graph). Première règle d'une boucle de fond : toute exception d'un cycle est loggée et avalée — un serveur IMAP grognon ne doit jamais tuer le process. Chaque cycle se termine par un watchdog (recover_stuck_requests) qui libère les pièces jointes bloquées en processing depuis plus de 15 min.

Le problème à deux réplicas

En prod, l'API tourne en plusieurs réplicas — et chacun exécute SA boucle de polling. Sans coordination : chaque email traité N fois. La solution tient en un pattern PostgreSQL :

-- chaque réplica "réclame" les mailboxes dues, sans jamais entrer en collision :
SELECT * FROM email_mailbox_config
WHERE last_polled_at IS NULL OR last_polled_at < now() - poll_interval
FOR UPDATE SKIP LOCKED LIMIT 10;
UPDATE ... SET last_polled_at = now();   -- bump AVANT le poll, commit avant l'I/O lente

FOR UPDATE SKIP LOCKED : les lignes verrouillées par un autre réplica sont sautées au lieu d'attendre — chaque réplica obtient un sous-ensemble disjoint, sans coordinateur, sans queue externe. Et le bump de last_polled_at avant le travail donne un TTL de récupération implicite : un réplica qui crashe en plein poll laisse la mailbox redevenir « due » au cycle suivant.

La dédup : la contrainte UNIQUE est l'autorité

Deux réplicas peuvent quand même recevoir le même email (courses résiduelles). La défense finale n'est pas un SELECT préalable — c'est la base :

# fast-path : SELECT sur (source='email', source_ref=message_id) — une OPTIMISATION
# autorité  : la contrainte UNIQUE, sous SAVEPOINT
async with session.begin_nested():                    # SAVEPOINT
    session.add(Request(source="email", source_ref=message_id, ...))
    try:
        await session.flush()
    except IntegrityError:                             # l'autre réplica a gagné
        return False                                   # doublon, transaction externe intacte

Le begin_nested() (SAVEPOINT) est le détail pro : l'IntegrityError annule seulement l'insert tenté, pas toute la transaction en cours. Retiens la hiérarchie : le SELECT évite du travail, la contrainte garantit l'exactitude.

Dernier raffinement, le tri des pièces jointes : seules les parts attachment extractibles (PDF/Excel/CSV) partent en pipeline — les images inline (logos, signatures, bannières < 50 Ko) sont filtrées par heuristique. S'il n'y a aucune PJ, le corps du mail devient le document ; s'il y en a une, le corps voyage en context_text à côté (le LLM voit les deux dans le même appel — évite les lignes en double).

🧩 Quiz1/4

Comment deux réplicas se partagent-ils les mailboxes sans coordinateur ?

🃏 Flashcards1/4