Skip to content
ZERONE
Torna agli insights
Data Engineering2026-04-18 · 4 min di letturaDa Case 03

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