Fire-and-forget e-mail v service vrstvě + CLI skript = tichá ztráta zprávy
import { Aside } from ‘@astrojs/starlight/components’;
Symptom
Sekce “Symptom”Backfill / agent skript vystaví transakční entitu (faktura, smlouva, objednávka), zavolá produkční service funkci typu createAndSendXxx(), vypíše „OK, entita vystavena” a ukončí proces přes process.exit(0) ve finally bloku.
V DB se entita objeví se status="SENT", UI hlásí „Odesláno klientovi” — všechno vypadá v pořádku. Realita:
- Klient žádný e-mail nedostal.
- V transakčním
EmailLogpo dané entitě neexistuje žádný záznam. pdfUrlna entitě je prázdný — PDF se nikdy nevygeneroval.- SMTP server nemá v queue ani delivery logu žádný odchozí mail.
Vyplave to typicky až několik dní po incidentu, kdy klient sám zavolá nebo napíše s dotazem typu „kam mám platit zálohu, nikde jsem nedostal QR kód ani číslo účtu”.
Root cause
Sekce “Root cause”Service funkce skládá vystavení (synchronní DB transakce) a odeslání e-mailu (asynchronní generování PDF + SMTP) za sebe, ale e-mail spouští jako fire-and-forget IIFE bez awaitu:
export async function createAndSendXxx(input, creatorId) { // 1) Synchronní část: vytvoř entitu v DB const entity = await prisma.$transaction(async (tx) => { return tx.entity.create({ data: { ...input, status: "SENT", // ← optimistic update, nastaveno PŘED e-mailem }, }); });
// 2) Asynchronní část: fire-and-forget IIFE if (client.email) { (async () => { const pdf = await generatePdf(entity); const result = await sendEmail({ to: client.email, attachments: [pdf] }); await prisma.emailLog.create({ data: { entityId: entity.id, ... } }); })(); // ← spuštěno bez awaitu, návratová Promise zahozena }
return { ok: true, entityId: entity.id };}V dlouhoběžícím web procesu (Next.js / Node server běžící pod PM2) IIFE doběhne v event loopu na pozadí — autor service to očekává. HTTP handler vrátí 200 hned, IIFE pár sekund poté skončí odeslání a uloží log. Funguje to spolehlivě.
V krátkoběžícím CLI skriptu (typicky agent run, backfill, migration) ale nastává:
async function main() { const result = await createAndSendXxx(input, userId); console.log(`OK: ${result.entityId}`);}
main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => process.exit(0)); // ← zabije proces uprostřed IIFEmain() doběhne hned po synchronní části (entita vznikla), process.exit(0) ukončí event loop uprostřed generatePdf nebo SMTP handshake. EmailLog.create se nikdy nezavolá → neexistuje žádná stopa, že odeslání proběhlo. DB-side status="SENT" je předzapsaný před emailem (optimistic update), takže UI hlásí „Odesláno” → false-positive.
Žádný error log, žádný alert. Bug je tichý.
Fix
Sekce “Fix”Akutně (doručit chybějící zprávu)
Sekce “Akutně (doručit chybějící zprávu)”Pokud má systém už existující synchronní „Send” endpoint (POST /entities/:id/send-email), použij ho — endpoint awaituje SMTP a uloží EmailLog:
// V autentizovaném REST volání (např. přes Playwright nebo admin session)const r = await fetch(`/api/entities/${id}/send-email`, { method: "POST", headers: { "Content-Type": "application/json", cookie: adminSessionCookie }, body: JSON.stringify({}),});// { success: true, sentTo: "...", smtpStatus: "delivered", ... }Refactor service (dlouhodobě)
Sekce “Refactor service (dlouhodobě)”Tři možnosti, podle priorit:
// Varianta A: await IIFE — pozor, prodlouží response čas o čas odesláníawait (async () => { /* PDF + email + log */ })();
// Varianta B: vrať promise jako součást výsledku — caller rozhodnereturn { ok: true, entityId: entity.id, emailPromise: dispatchEmailAsync(entity, client),};// HTTP handler ji ignoruje, CLI skript ji awaituje.
// Varianta C: globální tracker pending operationsconst pendingEmails = new Set<Promise<unknown>>();const p = dispatchEmailAsync(entity, client);pendingEmails.add(p);p.finally(() => pendingEmails.delete(p));
// Helper pro CLI:export async function flushPendingTasks() { await Promise.allSettled([...pendingEmails]);}Pro CLI skripty (universal fallback)
Sekce “Pro CLI skripty (universal fallback)”async function main() { await createAndSendXxx(input, userId);}
main() .catch((e) => { console.error(e); process.exitCode = 1; }) .finally(async () => { // Místo process.exit() — drainni event loop await flushPendingTasks(); // process sám skončí, jakmile není co dělat (default exitCode = 0) });Alternativně: process.exitCode = 0 místo process.exit() — Node sám počká na drain event loopu (žádné aktivní handles).
Audit zpětně
Sekce “Audit zpětně”Najdi další zombie entity ze stejného skriptu:
-- Entity která jsou v DB SENT, ale nikdy nešly přes EmailLogSELECT i.number, i.created_atFROM invoice iLEFT JOIN email_log el ON el.subject ILIKE CONCAT('%', i.number, '%')WHERE i.status = 'SENT' AND i.created_at BETWEEN '<incident start>' AND '<incident end>' AND el.id IS NULL;Každou identifikovanou entitu pošli ručně přes synchronní send endpoint.
Avoidance (detection + prevention)
Sekce “Avoidance (detection + prevention)”Antipattern flag
Sekce “Antipattern flag”Kdykoliv service vrstva spouští významnou side-effect (SMTP, webhook, billing call) v (async () => {})() bez awaitu a zároveň ji volá nějaký krátkoběžící entrypoint:
- CLI skript (backfill, migration, one-shot data job)
- Cron task který hned po práci exituje
- AWS Lambda / serverless function (frozen po response)
- Edge runtime function s timeoutem
Buď side-effect awaitovat, nebo explicitně dokumentovat lifecycle assumption v JSDoc:
/** * ⚠️ MUSÍ být voláno z long-running procesu (web server, daemon). * E-mail se odesílá fire-and-forget — krátký CLI skript ho ztratí. * Pro CLI použij `sendEntityEmail(entityId)` (synchronní) nebo awaituj * návratovou `emailPromise`. */export async function createAndSendXxx(...) { ... }Detection signal
Sekce “Detection signal”Status divergence monitor:
SELECT date_trunc('day', i.created_at) AS day, COUNT(*) FILTER (WHERE i.status = 'SENT') AS db_sent, COUNT(DISTINCT el.entity_id) FILTER (WHERE el.id IS NOT NULL) AS actually_emailed, COUNT(*) FILTER (WHERE i.status = 'SENT') - COUNT(DISTINCT el.entity_id) FILTER (WHERE el.id IS NOT NULL) AS gapFROM invoice iLEFT JOIN email_log el ON el.entity_id = i.idWHERE i.created_at > now() - interval '7 days'GROUP BY 1HAVING COUNT(*) FILTER (WHERE i.status = 'SENT') - COUNT(DISTINCT el.entity_id) FILTER (WHERE el.id IS NOT NULL) > 0ORDER BY 1 DESC;gap > 0 indikuje zombie entity. Alertuj.
Status pole — single source of truth
Sekce “Status pole — single source of truth”Místo jednoho pole status="SENT" použij dvě:
ALTER TABLE entity ADD COLUMN issued_at TIMESTAMPTZ;ALTER TABLE entity ADD COLUMN email_dispatched_at TIMESTAMPTZ;-- issued_at: nastaveno v DB transakci (vystaveno v systému)-- email_dispatched_at: nastaveno až po SMTP returnu (skutečně odesláno)UI pak ukazuje „Vystaveno” vs „Odesláno klientovi” rozlišeně. DB nelže.
Skript validation
Sekce “Skript validation”Před produkčním backfill skriptem zkus:
node --trace-exit ./scripts/backfill.ts# Pokud trace ukáže exit DŘÍV než SMTP returny → bug.
# Nebo přidej probe:process.on("beforeExit", (code) => { console.log(`[beforeExit] code=${code}, active handles:`, process._getActiveHandles?.()?.length);});Pokud skript končí dřív než SMTP odpovědi nebo s active handles > 0, něco visí.
Sister bugs
Sekce “Sister bugs”- Optimistic-write antipattern obecně: DB stav předbíhá realitu (status=“DONE” před skutečným dokončením async úkolu). Tento bug je jeho instance.
- Serverless background tasks: stejný symptom v AWS Lambda — Lambda zmrazí kontext hned po vrácení response, IIFE se zabije uprostřed. Řešení:
context.callbackWaitsForEmptyEventLoop = true(Lambda Node) nebo waitUntil() (Cloudflare Workers). - Edge runtime e-mailing: Vercel/Next.js edge functions mají timeout ~30s a po vrácení response umrtví context. Stejný pattern.
Klíčové ponaučení
Sekce “Klíčové ponaučení”Pokud je side-effect podstatný pro byznys (klient ho čeká), nesmí být fire-and-forget. Buď ho awaituj, nebo ho vrať volajícímu. Tichá ztráta transakční komunikace je horší než pomalá odpověď.