Skip to content
ZERONE
Natrag na uvide
Data Engineering2026-04-18 · 4 min čitanjaIz Case 03

Commit prije async I/O-a: kako je jedan enricher držao cijeli PgBouncer pool u idle-u

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

Slika je izgledala nedužno: email-enricher daemon čita batch kandidata iz baze, provjerava email domene preko vanjskog validation API-ja, upisuje rezultat. Na devu: radi. Pojedinačni testovi: ništa sumnjivo. U produkcijskom klasteru s dvanaest paralelnih particija: backend 502 svakih nekoliko minuta.

Stack trace nije pokazivao enricher — pokazivao je nepovezane endpointe koji su čekali connection koji nikad nije stigao. PgBouncer pool: iscrpljen. A enricher je na monitoru pokazivao samo ~4 aktivna upita. Kako?

Tihi ubojica

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 connection dok se ne izađe iz bloka. Unutar bloka petlja poziva vanjski API 200 puta. 200 × ~500 ms = 100 sekundi po batchu. Sve to vrijeme connection je idle in transaction.

PgBouncer obično čeka 30–60 sekundi pa izbacuje idle-in-transaction connectione. Rezultat: backend upiti ne dobivaju connection, timeout, 502. Monitor pokazuje samo tri aktivna SELECT-a — dvanaest idle-in-tx connectiona enrichera mnogi monitoring setupi ne broje kao "aktivne".

Popravak

Pravilo: commit odmah nakon SELECT-a, prije bilo kakvog async I/O-a.

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 zatvorena, connection nazad u pool

    # Faza 2: HTTP pozivi bez otvorenog DB connectiona
    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},
            )

Connection je sada otvoren samo za čiste DB operacije — milisekunde, ne minute.

Operativna posljedica

Pravilo kao review pattern:

"Svaka async funkcija koja radi DB + vanjski I/O ima najmanje dvije transakcije."

Uz to postavljamo idle_in_transaction_session_timeout=5s u PgBounceru — zaboravljena transakcija blokira pool najviše pet sekundi, nakon čega je PostgreSQL prekida. Strogo, ali proračunato: bolje jedna pogreška daemona nego pad cijelog backenda.

Zašto testovi to ne hvataju

Lokalno s jednim workerom i lokalnom bazom: nema iscrpljivanja poola. Lokalno s mockanim API-jem: nema I/O kašnjenja. Anti-pattern je u kodu nerazlučiv od ispravnog koda — pojavi se tek pod stvarnim opterećenjem. Zato review gate pripada u pipeline: PR-ovi koji sadrže await db.begin() i importaju HTTP klijente dobivaju automatski label i traže izričiti human review.

Sličan izazov i kod vas?

Vjerojatno smo već vidjeli nešto slično. Razgovarajmo.

Započnite razgovor