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.