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.