Skip to content
ZERONE
Nazaj na vpoglede
Data Engineering2026-04-18 · 4 min branjaIz Case 03

Commit pred async I/O: kako je en enricher držal celoten PgBouncer pool v idle

Transakcija, ki čaka HTTP odgovor, je v connection poolu nevidna — vendar drži slot. Z dvanajstimi paralelnimi daemoni je dovolj, da celoten backend pade na 502.

Slika je izgledala nedolžna: email-enricher daemon prebere batch kandidatov iz baze, preveri email domene proti zunanjemu validation API-ju, rezultat zapiše nazaj. Na devu: deluje. Posamezni testni zagoni: nič nenavadnega. V produkcijski gruči z dvanajstimi vzporednimi particijami: 502 na backendu vsakih nekaj minut.

Stack trace ni kazal na enricher — kazal je na nepovezane endpointe, ki so čakali na connection, ki nikoli ni prišla. PgBouncer pool: izčrpan. A enricher je na monitorju kazal le ~4 aktivne poizvedbe. Kako?

Tihi morilec

Koda je izgledala tako:

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 na klic
            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() odpre transakcijo in zadrži connection, dokler ne izstopiš iz bloka. Znotraj bloka zanka 200-krat pokliče zunanji API. 200 × ~500 ms = 100 sekund na batch. Ves ta čas: connection je idle in transaction.

PgBouncer običajno počaka 30–60 sekund, nato izvrže idle-in-transaction connectione. Posledica: backend poizvedbe ne dobijo connectiona, timeout, 502. Monitor prikazuje samo tri aktivne SELECT-e — dvanajst idle-in-tx connectionov enricherja mnogi monitoring setupi ne štejejo kot "aktivne".

Popravek

Pravilo: commit takoj po SELECT-u, pred kakršnim koli asinhronim I/O.

async def enrich_batch():
    # Faza 1: SELECT, nato takoj 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 zaprta, connection nazaj v pool

    # Faza 2: HTTP klici brez odprtega DB connectiona
    results = []
    for row in rows:
        r = await validate_email(row.email)
        results.append((row.id, r))

    # Faza 3: UPDATE batch v sveži kratki 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 zdaj odprt samo za čiste DB operacije — milisekunde, ne minute.

Operativna posledica

Pravilo kot review pattern:

"Vsaka async funkcija, ki počne DB + zunanji I/O, ima vsaj dve transakciji."

Poleg tega nastavimo idle_in_transaction_session_timeout=5s v PgBouncerju — pozabljena transakcija blokira pool največ pet sekund, po čemer jo PostgreSQL prekine. Trdo, a preračunano: bolje ena daemonska napaka kot izpad celotnega backenda.

Zakaj testi tega ne ujamejo

Lokalno z enim workerjem in lokalno bazo: brez izčrpavanja poola. Lokalno z mock API-jem: brez I/O zakasnitve. Anti-vzorec je v kodi nerazločljiv od pravilne kode — pokaže se le pod resnično obremenitvijo. Zato review gate sodi v pipeline: PR-ji, ki vsebujejo await db.begin() in uvažajo HTTP kliente, dobijo samodejni label in zahtevajo izrecni človeški review.

Podoben izziv tudi pri vas?

Verjetno smo že videli kaj podobnega. Pogovorimo se.

Začnimo pogovor