Vissza a Journal-hoz
netorigohu

Webhook + outbox minta ERP-ben — exactly-once Finance → Logistics kozott

Finance → Logistics exactly-once: outbox tabla, idempotency-kulcs, exp_backoff retry, consumer-side processed_events. 18 honap, nulla duplikatum.

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:

  1. 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.
  2. 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.