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.