Commit prima dell'I/O asincrono: come un enricher ha tenuto idle l'intero pool PgBouncer
Una transazione in attesa di una risposta HTTP è invisibile nel connection pool — ma occupa lo slot. Con dodici daemon paralleli basta a portare un backend intero al 502.
La scena sembrava innocua: un daemon di email-enrichment legge un batch di candidati dal database, controlla i domini email contro un'API di validazione esterna, scrive il risultato. In dev: funziona. Test isolati: nulla di sospetto. Nel cluster di produzione con dodici partizioni parallele: 502 sul backend ogni pochi minuti.
Lo stack trace non puntava all'enricher — puntava a endpoint estranei che aspettavano una connection che non arrivava mai. Pool PgBouncer: esaurito. Eppure l'enricher mostrava solo ~4 query attive sul monitor. Come?
Il killer silenzioso
Il codice era così:
async def enrich_batch():
async with db.begin() as conn:
rows = await conn.execute(
"SELECT id, email FROM job_advertisement "
"WHERE email_verified IS NULL LIMIT 200"
)
for row in rows:
# HTTP async — 200-1200 ms per chiamata
result = await validate_email(row.email)
await conn.execute(
"UPDATE job_advertisement SET email_verified=:v WHERE id=:id",
{"v": result, "id": row.id},
)
Problema: async with db.begin() apre una transazione e tiene la connection finché si esce dal blocco. Dentro il blocco il loop chiama un'API esterna 200 volte. 200 × ~500 ms = 100 secondi per batch. Per tutto quel tempo: la connection è idle in transaction.
PgBouncer di solito aspetta 30–60 secondi e poi espelle le connection idle-in-transaction. Risultato: le query del backend non ottengono connection, timeout, 502. Il monitor mostra solo tre SELECT attive — le dodici connection idle-in-tx dell'enricher non vengono contate come "attive" da molti setup di monitoraggio.
Il fix
La regola: commit subito dopo la SELECT, prima di qualsiasi I/O asincrono.
async def enrich_batch():
# Fase 1: SELECT, poi commit immediato
async with db.begin() as conn:
rows = await conn.execute(
"SELECT id, email FROM job_advertisement "
"WHERE email_verified IS NULL LIMIT 200"
)
rows = rows.fetchall()
# Transazione chiusa, connection di nuovo nel pool
# Fase 2: chiamate HTTP senza connection DB aperta
results = []
for row in rows:
r = await validate_email(row.email)
results.append((row.id, r))
# Fase 3: batch UPDATE in una transazione nuova e corta
async with db.begin() as conn:
for rid, r in results:
await conn.execute(
"UPDATE job_advertisement SET email_verified=:v WHERE id=:id",
{"v": r, "id": rid},
)
La connection ora è aperta solo per le operazioni DB pure — millisecondi, non minuti.
Conseguenza operativa
La regola come pattern di review:
"Ogni funzione async che fa DB + I/O esterno ha almeno due transazioni."
In più impostiamo idle_in_transaction_session_timeout=5s in PgBouncer — una transazione dimenticata blocca il pool al massimo cinque secondi, dopodiché PostgreSQL la termina. Duro ma calcolato: meglio un singolo errore di daemon che un'intera caduta del backend.
Perché i test non lo intercettano
In locale con un singolo worker e DB locale: nessuna esaurimento del pool. In locale con API mockata: nessun ritardo di I/O. L'anti-pattern nel codice è indistinguibile dal codice corretto — emerge solo sotto carico reale. Per questo un review gate appartiene alla pipeline: i PR che contengono await db.begin() e importano client HTTP ricevono un label automatico e richiedono review umano esplicito.
Stesso fuoco anche da te?
Probabilmente abbiamo già visto qualcosa di simile. Parliamone.
Iniziamo a parlarne→