Egy klasszikus integracios bug: a Finance modul szamlat allit ki, kuld egy webhookot a Logistics moduinak („szamla kifizetve, indithatod a szallitast”), de a webhook-deplete sikeretlen — network blip, Logistics deploy alatt, akarmi. A Finance ujraprobalkozik. Ket csomag elindul. Az ugyfel ket szamlat kap. A reszamnezet a CTO-nal landol. Az alabbi cikkben a kiut: az outbox table + idempotency-kulcs + retry mintazat, ahogy a Netorigon hasznaljuk Finance → Logistics kozott.
Miert nem direkt HTTP-hivas
A naiv megoldas: Finance kozvetlenul HTTP POST-ot kuld a Logistics-nak. Ha hiba van, retry. De ket problema:
- Tranzakcio-konzisztencia hianyzik. A Finance az adatbazis-tranzakcio-jaban kuldi a webhookot — ha a tranzakcio rollback-el (mert valami mas hiba), a webhook mar elment, es a Logistics elinditja a folyamatot egy nem-letezo szamlara.
- Retry nem rolling. Ha a Logistics 30 mp-ig nem elerheto, a Finance-ban az osszes szamlazo-action megall ennyi ideig.
A kovetkezo lepes: outbox table.
Az outbox tabla
A Finance modul minden esemenyét (peldaul InvoicePaidEvent) NEM kozvetlenul HTTP-n keresztul, hanem egy finance_outbox tablaba INSERT-eli — UGYANABBAN A TRANZAKCIOBAN, mint amelyikben az invoices.status = 'paid'-t allit. Egy SQL tranzakcio, ket INSERT/UPDATE: ha barmi rollback-el, mindketto rollback-el.
A tabla:
CREATE TABLE finance_outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL, -- 'invoice.paid', 'invoice.refunded', stb.
payload JSONB NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
delivery_status TEXT NOT NULL DEFAULT 'pending', -- pending | delivered | failed
delivery_attempts INT NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON finance_outbox (delivery_status, next_attempt_at);
Az idempotency_key az esemeny stable azonosítója — peldaul invoice.paid:<invoice_id>:<payment_id>. Ha a Finance ket reszamlazasi event-et probal kuldeni ugyanarra a (invoice_id, payment_id) parra, a UNIQUE constraint megakadalyozza.
A delivery worker
Egy kulonallo NestJS scheduler @Cron('*/5 * * * * *') (5 masodpercenkent) megnezi az outboxot:
SELECT * FROM finance_outbox
WHERE delivery_status = 'pending'
AND next_attempt_at <= NOW()
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
A FOR UPDATE SKIP LOCKED biztositja, hogy ket worker-instance nem proba ugyanazt az esemenyt elkuldeni. A worker minden eventre megfogja a celcim webhook URL-jet (https://logistics.netorigo.svc/webhooks/finance), HTTP POST-tal kuldi a payload-ot + idempotency-kulcsot a X-Idempotency-Key headerben.
Ha 2xx jott vissza: delivery_status = 'delivered', delivered_at = NOW(). Ha 5xx vagy timeout: delivery_attempts += 1, next_attempt_at = NOW() + exp_backoff(attempts) (5 mp, 30 mp, 5 perc, 30 perc, 4 ora). 8 attempt utan: delivery_status = 'failed', alert egy Slack channelra.
A consumer oldal: idempotency-kulcs hasznalata
A Logistics modul webhook-controller-e a kovetkezot teszi:
@Post('/webhooks/finance')
async handleFinanceWebhook(@Headers('x-idempotency-key') key: string, @Body() body: any) {
const existing = await this.processedEventsRepo.findOne({ idempotency_key: key });
if (existing) {
// Mar feldolgoztuk, valaszolj 200-zal
return { status: 'ok', already_processed: true };
}
await this.processedEventsRepo.insert({
idempotency_key: key,
received_at: new Date(),
payload: body,
});
// Most dolgozzuk fel
await this.shipmentService.handleInvoicePaid(body);
return { status: 'ok' };
}
A logistics_processed_events.idempotency_key UNIQUE — ha a Finance worker kétszer kuldi az eseményt (peldaul mert az elso 200-as valasz timeout-on elveszett), a masodik feldolgozaskor a processedEventsRepo.findOne mar megtalalja, es a Logistics nem inditja el masodszor a szallitast.
Ez az exactly-once at-the-consumer mintazat: a Finance at-least-once kuld, a Logistics exactly-once dolgoz fel.
Cleanup
A finance_outbox delivered = true sorai 30 nap utan archivaltak (finance_outbox_archive), 1 ev utan torolve. A logistics_processed_events sorai 90 nap utan torolve — utana ugyis a Finance worker nem fog mar retryt kuldeni.
A teljes ide-ut
Finance writes invoice.paid → finance_outbox INSERT (same TX) → cron worker pickups row → HTTP POST to Logistics with X-Idempotency-Key → Logistics processed_events check + INSERT (same TX) → shipment-state changes to waiting → 200 OK → Finance marks outbox row delivered.
Ha barhol megakad — a Logistics ki van kapcsolva, a network down van, a Finance ujraindul, a Logistics ujraindul — a folyamat a kovetkezo cron-iteracioban folytatodik, exactly-once garanciaval.
Vegszo
A webhook + outbox + idempotency-kulcs minta a moduláris ERP-k legbiztosabb integracios alapja. Egy 30 soros tablazat + egy 100 soros scheduler + egy 20 soros consumer guard megold egy olyan problemat, ami klasszikus integraciokban hetente egy support-ticketet generál. 18 honap production-ban nulla duplikalt szamla, nulla elveszett szallitas — a minta mukodik.