Polling email et déduplication multi-réplica
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).
Comment deux réplicas se partagent-ils les mailboxes sans coordinateur ?