ZERONE
Nazad na Insights
Data Engineering2026-04-18 · 4 min čitanja

Commit pre async I/O: kako je jedan enricher držao ceo PgBouncer pool u idle-u

Transakcija koja čeka HTTP odgovor nevidljiva je u connection pool-u — ali drži slot. Sa dvanaest paralelnih demona dovoljno je da ceo backend padne na 502.

Scena je izgledala bezazleno: email-enricher demon čita batch kandidata iz baze, proverava domene preko eksternog validation API-ja, piše rezultat nazad. Na dev-u: radi. Pojedinačni testovi: ništa sumnjivo. U produkcionom klasteru sa dvanaest paralelnih particija: backend 502 svakih par minuta.

Stack trace nije pokazivao enricher — pokazivao je druge endpoint-e koji čekaju konekciju koja nikada ne stiže. PgBouncer pool: iscrpljen. Ali: enricher je po monitoru imao tek ~4 aktivna query-ja. Kako?

Tihi ubica

Kod je izgledao ovako:

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:
            # Async HTTP — 200–1200 ms po pozivu
            result = await validate_email(row.email)
            await conn.execute(
                "UPDATE job_advertisement SET email_verified=:v WHERE id=:id",
                {"v": result, "id": row.id},
            )

Problem: async with db.begin() otvara transakciju i drži konekciju do kraja bloka. Unutar bloka, petlja zove eksternu API 200 puta. 200 × ~500 ms = 100 sekundi po batch-u. Sve to vreme: konekcija je idle-in-transaction.

PgBouncer obično čeka 30–60 sekundi pa kikuje konekcije u idle-in-transaction stanju. Rezultat: backend upiti ne dobijaju konekciju, timeout, 502. Monitor pokazuje samo tri aktivna SELECT-a — dvanaest idle-in-tx enricher konekcija mnogi monitori ne broje kao „aktivne".

Popravka

Pravilo: Commit odmah posle SELECT-a, pre bilo kakvog async I/O.

async def enrich_batch():
    # Faza 1: SELECT, pa odmah commit
    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()
    # Transakcija je zatvorena, konekcija nazad u pool

    # Faza 2: HTTP pozivi bez otvorene konekcije
    results = []
    for row in rows:
        r = await validate_email(row.email)
        results.append((row.id, r))

    # Faza 3: UPDATE batch u novoj kratkoj transakciji
    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},
            )

Konekcija je sada otvorena samo za čiste DB operacije — milisekunde, ne minuti.

Operativna posledica

Pravilo kao review pattern:

„Svaka async funkcija koja radi DB + eksterni I/O ima najmanje dve transakcije."

Dodatno: idle_in_transaction_session_timeout=5s u PgBouncer-u — zaboravljena transakcija blokira pool najviše 5 sekundi, posle toga PostgreSQL je ubije. Tvrdo, ali kalkulisano: bolje jedan pad demona nego ceo backend.

Zašto testovi ne hvataju

Lokalno sa jednim worker-om + lokalnom bazom: nema iscrpljivanja pool-a. Lokalno sa mock API-jem: nema I/O kašnjenja. Anti-pattern se u kodu ne razlikuje od ispravnog — samo u ponašanju pod load-om. Zato review gate u pipeline: PR-ovi sa await db.begin() + HTTP klijenti automatski dobijaju label i traže eksplicitni human review.

Sličan požar?

Verovatno smo već videli nešto slično. Javite se.

Započni razgovor