Mesaj kimliği ve sahiplik

Olay güdümlü mimaride aynı mesaj yeniden teslim edilebilir. Tüketici her teslimatı yeni bir iş sayarsa aynı sipariş için tekrar yan etki üretebilir. Buradaki hedef broker için 'exactly once' sözü vermek değil; aynı event kimliğinin bu handler'ın veritabanında ikinci iş satırı oluşturmasını önlemek.

Üretici aynı olayın yeniden teslimatında kararlı bir kimlik taşımalı. Aynı kimlikle farklı payload gelirse onu sıradan tekrar gibi sessizce kabul etmek yerine çakışma olarak incelemek gerekir. Farklı kimliklerin aynı siparişi anlattığı durum ise ayrı iş kuralıdır; event kimliğiyle deduplication bunu kendiliğinden çözmez.

Örneğin kapsamı

Aşağıdaki kod Node.js v24.19.0 üzerinde çalıştırılmış yerel bir örnektir. node:sqlite ile bellekte veritabanı açar; processed ve shipments tablolarına yazar. shipments burada yalnızca bir veritabanı satırı: kargo servisi, ödeme, broker veya müşteri sistemi çağrılmıyor.

processed.event_id birincil anahtar; shipments.event_id benzersiz. İşlenmiş kimlik ve iş sonucu tek transaction içinde tutuluyor. İş satırı eklenmeden hata olursa transaction geri alınıyor; böylece yeniden deneme daha önce tamamlanmış gibi işaretlenmiyor.

Kodu ve testleri çalıştırın

Kaynak bağlantısından event-idempotency.mjs dosyasını kaydedin. Node.js 24 ile node event-idempotency.mjs komutunu çalıştırın. Ek npm paketi veya veritabanı sunucusu gerekmez. node:sqlite sürüme bağlı bir API olduğundan belgeleri kullandığınız Node sürümüne göre kontrol edin.

JavaScript
// Node.js 24, in-memory SQLite. No broker or external shipment is simulated.
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`CREATE TABLE processed(event_id TEXT PRIMARY KEY, order_id TEXT NOT NULL);
  CREATE TABLE shipments(id INTEGER PRIMARY KEY, event_id TEXT UNIQUE NOT NULL, order_id TEXT NOT NULL);`);
function handle(event, { fail = false } = {}) {
  db.exec('BEGIN IMMEDIATE');
  try {
    const saved = db.prepare('SELECT order_id FROM processed WHERE event_id = ?').get(event.id);
    if (saved) {
      if (saved.order_id !== event.orderId) throw new Error('Event identity conflict');
      const result = db.prepare('SELECT id FROM shipments WHERE event_id = ?').get(event.id);
      db.exec('COMMIT');
      return { id: Number(result.id), duplicate: true };
    }
    db.prepare('INSERT INTO processed VALUES (?, ?)').run(event.id, event.orderId);
    if (fail) throw new Error('Injected failure before shipment insert');
    const row = db.prepare('INSERT INTO shipments(event_id, order_id) VALUES (?, ?)').run(event.id, event.orderId);
    db.exec('COMMIT');
    return { id: Number(row.lastInsertRowid), duplicate: false };
  } catch (error) {
    db.exec('ROLLBACK');
    throw error;
  }
}
const event = { id: 'evt-1', orderId: 'order-1' };
const first = handle(event), replay = handle(event);
assert.equal(first.id, replay.id);
assert.equal(replay.duplicate, true);
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM shipments').get().n, 1);
console.log('duplicate delivery: 2 calls, 1 shipment row');
const retry = { id: 'evt-2', orderId: 'order-2' };
assert.throws(() => handle(retry, { fail: true }), /Injected failure/);
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM processed WHERE event_id = ?').get(retry.id).n, 0);
handle(retry);
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM shipments').get().n, 2);
console.log('rollback and retry: failed event not marked processed, retry succeeds');
assert.throws(() => handle({ ...event, orderId: 'other-order' }), /identity conflict/);
console.log('identity conflict: rejected');
db.close();
console.log('PASS: duplicate, rollback/retry and identity-conflict checks');

Gerçek test çıktısı

İlk test evt-1'i iki kez işliyor; iki çağrı aynı satır kimliğini döndürüyor ve toplam shipment satırı 1 kalıyor. İkinci test evt-2 için işlenmiş kaydı ekledikten sonra hata veriyor; rollback sonrası bu kimliğin processed kaydı 0. Aynı event tekrar işlenince toplam iş satırı 2 oluyor.

Üçüncü test evt-1 kimliğini farklı orderId ile gönderiyor ve identity conflict hatasını doğruluyor. Bu sayılar yerel assertion sonuçlarıdır; üretim yük testi, dağıtık yarış testi veya broker teslimat garantisi değildir.

Terminal
duplicate delivery: 2 calls, 1 shipment row
rollback and retry: failed event not marked processed, retry succeeds
identity conflict: rejected
PASS: duplicate, rollback/retry and identity-conflict checks

Transaction sınırının dışı

Bellekteki veri süreç kapanınca kaybolur. Üretimde kalıcı depolama, eşzamanlı worker testleri, saklama süresi ve event şema sürümü gerekir. BEGIN IMMEDIATE bu tek bağlantılı örneğin transaction'ını açıyor; dağıtık kilitleme çözümü olduğu iddia edilmiyor.

Bir dış API çağrısı SQLite transaction'ının parçası değildir. Dış servis çağrısı başarılı olup yerel kayıt başarısız olursa bu örnek tek başına tekrar yan etkiyi engelleyemez. Böyle bir akış için outbox, dış servisin idempotency anahtarı ve uzlaştırma politikası birlikte değerlendirilmelidir. Mesajı onaylama zamanı da kalıcı iş kaydıyla ilişkilendirilmelidir.

Yayına geçmeden önce

Tekrar teslimat, işlem ortasında hata, farklı payload ile aynı kimlik ve yeni kimlikle aynı iş senaryolarını ayrı test edin. Ölçülen sonucu yalnızca test edilen sınır için raporlayın. Kök nedeni izlemek için aşağıdaki debugging rehberini, değerlendirme kaydını sürümlemek için RAG yazısındaki iz yaklaşımını inceleyebilirsiniz.

Kaynak: Çalıştırılabilir idempotency örneği

Kaynak: Node.js SQLite API belgeleri

Kaynak: SQLite transaction belgeleri

Kaynak: İlgili rehber: hata ayıklama

Kaynak: İlgili yazı: RAG değerlendirme izleri