Debugging audit log gagal usai blacklist vendor di backend sering terlihat sebagai bug yang membingungkan karena request API tampak berhasil, data vendor berubah, tetapi jejak audit tidak tercatat dan status kepatuhan di sistem lain tidak ikut sinkron. Dalam praktiknya, ini biasanya bukan satu bug tunggal, melainkan kombinasi masalah konsistensi data: transaksi database terpisah, worker queue yang berjalan terlalu cepat, cache yang stale, dan webhook yang tidak idempoten.

Studi kasus di artikel ini memakai konteks pelarangan vendor tertentu oleh regulator, terinspirasi dari skenario blacklist terhadap perusahaan teknologi seperti Palantir. Fokusnya bukan pada aspek politik atau legal, melainkan pada sisi backend: ketika status vendor diubah menjadi blacklisted, sistem harus memastikan perubahan utama, audit log, dan status kepatuhan tetap konsisten meskipun ada job asynchronous, cache, dan integrasi eksternal.

Gambaran masalah di produksi

Gejala awal yang muncul biasanya seperti ini:

  • Endpoint PATCH /vendors/:id atau POST /vendors/:id/blacklist mengembalikan status sukses.
  • Tabel vendors menunjukkan vendor sudah masuk blacklist.
  • Tabel audit_logs tidak memiliki entri yang sesuai, atau entri tercatat tetapi field perubahan tidak lengkap.
  • Sistem kepatuhan internal atau pihak ketiga masih menganggap vendor aktif.
  • Beberapa menit kemudian, status vendor di dashboard berbeda dengan status di laporan kepatuhan.

Secara bisnis, ini berbahaya. Blacklist vendor biasanya terkait kontrol risiko, legal hold, atau pembatasan pengadaan. Jika update hanya sukses parsial, sistem bisa menampilkan kondisi yang menyesatkan: vendor dianggap diblokir oleh satu modul, tetapi masih lolos di modul lain.

Arsitektur yang memicu inkonsistensi

Pola arsitektur yang sering memunculkan masalah ini kurang lebih seperti berikut:

  1. API menerima request blacklist vendor.
  2. Service backend mengubah status vendor di database utama.
  3. Service backend menulis audit log di operasi terpisah.
  4. Backend mengirim event atau enqueue job untuk sinkronisasi status kepatuhan.
  5. Layer cache menyimpan snapshot vendor untuk kebutuhan baca cepat.
  6. Webhook dari sistem eksternal bisa datang ulang atau datang terlambat.

Secara sekilas desain ini terlihat masuk akal. Masalah muncul ketika tiap langkah tidak berada dalam batas konsistensi yang jelas. Jika update vendor berhasil tetapi audit log gagal, atau job sinkronisasi berjalan sebelum transaksi utama benar-benar committed, sistem masuk ke keadaan setengah berhasil.

Root cause teknis yang ditemukan

1. Transaksi database terpisah

Bug paling umum adalah operasi update vendor dan insert audit log dilakukan di koneksi atau transaksi berbeda. Contohnya:

// Pseudocode yang bermasalah
function blacklistVendor(vendorId, actorId, reason) {
  db.beginTransaction();

  db.execute(
    "UPDATE vendors SET status = ?, blacklisted_at = NOW(), blacklist_reason = ? WHERE id = ?",
    ["blacklisted", reason, vendorId]
  );

  db.commit();

  auditDb.execute(
    "INSERT INTO audit_logs(entity_type, entity_id, action, actor_id, payload, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
    ["vendor", vendorId, "vendor_blacklisted", actorId, JSON.stringify({ reason })]
  );

  queue.publish("vendor.blacklisted", { vendorId });
}

Masalahnya jelas: status vendor sudah committed lebih dulu. Jika insert audit log gagal karena timeout, deadlock, atau constraint error, API mungkin tetap mengembalikan sukses. Hasilnya adalah partial success.

Penyebab audit log gagal bisa bermacam-macam:

  • Koneksi ke database audit berbeda dan sedang lambat.
  • Payload audit melebihi batas kolom atau format JSON tidak valid.
  • Constraint seperti actor_id NOT NULL gagal untuk request sistem internal.
  • Exception tertangkap tetapi tidak menyebabkan rollback request utama.

2. Race condition antara transaksi utama dan worker queue

Kasus berikutnya adalah job sinkronisasi status kepatuhan dipublish sebelum transaksi update vendor benar-benar committed. Worker yang cukup cepat akan membaca data vendor saat status lama masih terlihat.

// Pseudocode yang rawan race condition
function blacklistVendor(vendorId, actorId, reason) {
  db.beginTransaction();

  db.execute("UPDATE vendors SET status = 'blacklisted' WHERE id = ?", [vendorId]);
  queue.publish("sync-compliance-status", { vendorId });

  db.commit();
}

Jika worker mengambil job sebelum commit, ada dua kemungkinan:

  • Worker membaca status vendor yang masih active.
  • Worker gagal menemukan field terbaru dan memutuskan tidak ada perubahan yang perlu dikirim.

Ini terutama sering terjadi di sistem yang memakai queue in-memory atau broker dengan latensi rendah, sementara transaksi database memerlukan waktu lebih lama karena lock atau write contention.

3. Cache stale setelah status vendor berubah

Bug lain yang sering tersembunyi adalah invalidasi cache yang tidak sinkron. Misalnya:

  • API update menulis ke database.
  • Endpoint baca vendor memakai Redis atau cache aplikasi.
  • Worker sinkronisasi membaca dari service layer yang mengutamakan cache.
  • Cache vendor belum dihapus atau belum direfresh.

Akibatnya, worker atau service lain mengirim status lama ke sistem kepatuhan. Dari luar terlihat seolah sinkronisasi gagal, padahal ia hanya membaca representasi data yang stale.

4. Idempotensi webhook yang lemah

Masalah tidak berhenti di sisi outbound. Banyak integrasi kepatuhan memakai webhook callback untuk konfirmasi perubahan status. Jika endpoint webhook internal tidak idempoten, request retry dari provider dapat:

  • Mengubah state dua kali.
  • Menimpa status yang lebih baru dengan payload yang lebih lama.
  • Mencatat audit log duplikat.
  • Menganggap callback lama sebagai status final.

Contoh skenario berbahaya:

  1. Vendor diblacklist pada pukul 10:00.
  2. Sync pertama gagal timeout, lalu retry.
  3. Sistem eksternal mengirim callback lama dengan status sebelumnya karena proses tertunda.
  4. Backend menerima callback tanpa memeriksa versi event atau idempotency key.
  5. Status kepatuhan lokal kembali ke state yang tidak sesuai.

Langkah investigasi yang efektif

Saat menghadapi bug seperti ini, jangan mulai dari asumsi. Mulailah dari jejak eksekusi nyata per request. Tujuannya adalah menjawab tiga pertanyaan:

  1. Apakah update vendor benar-benar committed?
  2. Apakah audit log ditulis dalam unit kerja yang sama?
  3. Apakah sinkronisasi membaca state yang benar pada waktu yang benar?

1. Kumpulkan satu request yang bermasalah

Ambil satu contoh konkret dari production atau staging:

  • request_id atau trace_id
  • vendor_id
  • waktu request
  • aktor pengguna atau service account

Tanpa korelasi request, analisis log akan melebar dan sulit dibuktikan.

2. Periksa data utama, audit log, dan outbox/job

Query yang biasa diperiksa:

-- Status vendor saat ini
SELECT id, status, blacklisted_at, blacklist_reason, updated_at
FROM vendors
WHERE id = :vendor_id;

-- Audit log terkait vendor
SELECT id, entity_type, entity_id, action, actor_id, payload, created_at
FROM audit_logs
WHERE entity_type = 'vendor'
  AND entity_id = :vendor_id
ORDER BY created_at DESC;

-- Jika ada tabel outbox/event internal
SELECT id, aggregate_type, aggregate_id, event_type, payload, status, created_at, processed_at
FROM outbox_events
WHERE aggregate_type = 'vendor'
  AND aggregate_id = :vendor_id
ORDER BY created_at DESC;

-- Jika ada tabel sinkronisasi kepatuhan
SELECT vendor_id, compliance_status, external_ref, last_synced_at, updated_at
FROM vendor_compliance_status
WHERE vendor_id = :vendor_id;

Dari sini biasanya terlihat pola:

  • vendors.status = 'blacklisted' tetapi tidak ada audit log.
  • Ada audit log, tetapi tidak ada event untuk sinkronisasi.
  • Ada event sinkronisasi, tetapi status kepatuhan tetap lama.

3. Telusuri log aplikasi dengan korelasi yang sama

Cari log di sekitar request yang sama. Yang penting bukan banyaknya log, melainkan urutan waktunya:

[req=abc123] PATCH /vendors/42/blacklist started
[req=abc123] vendor status updated to blacklisted
[req=abc123] publish job sync-compliance-status vendor=42
[worker=queue-7 job=91] start sync-compliance-status vendor=42
[worker=queue-7 job=91] loaded vendor status=active
[req=abc123] audit log insert failed: null value in column actor_id
[req=abc123] request completed 200

Dari urutan di atas, dua masalah langsung terlihat:

  • Job berjalan sebelum state final terlihat oleh worker.
  • Kegagalan audit log tidak memengaruhi status respons API.

4. Validasi apakah cache ikut bermain

Jika worker atau service baca memakai cache, periksa apakah key vendor diperbarui atau dihapus setelah perubahan status. Misalnya:

GET vendor:42
TTL vendor:42

Jika cache masih berisi status active beberapa detik setelah update database, worker yang membaca via service biasa bisa salah memproses data.

5. Cek webhook retry dan duplikasi event

Untuk integrasi kepatuhan, audit request masuk juga perlu diperiksa:

  • Apakah provider mengirim ulang callback dengan header unik?
  • Apakah backend menyimpan event_id atau delivery_id untuk mencegah duplikasi?
  • Apakah callback lama bisa menimpa state baru?

Jika tidak ada mekanisme ini, inkonsistensi bisa terjadi meski operasi internal sudah benar.

Cara mereproduksi bug di staging

Bug seperti ini lebih mudah diperbaiki jika bisa direproduksi secara deterministik. Berikut pendekatan yang praktis:

1. Simulasikan kegagalan audit log

Buat kondisi di mana insert audit log gagal setelah update vendor berhasil. Misalnya dengan memaksa payload tidak valid, atau menonaktifkan koneksi audit di staging. Tujuannya untuk melihat apakah API tetap mengembalikan sukses.

2. Perlambat commit transaksi utama

Tambahkan delay singkat setelah update vendor tetapi sebelum commit, lalu percepat worker queue. Jika job membaca status lama, race condition terbukti.

// Pseudocode untuk reproduksi di staging
function blacklistVendor(vendorId, actorId, reason) {
  db.beginTransaction();
  db.execute("UPDATE vendors SET status = 'blacklisted', blacklisted_at = NOW() WHERE id = ?", [vendorId]);

  queue.publish("sync-compliance-status", { vendorId });

  sleep(2000); // hanya untuk reproduksi bug
  db.commit();
}

3. Nonaktifkan invalidasi cache sementara

Jika worker sinkronisasi membaca data lama hanya saat cache tidak dibersihkan, berarti penyebabnya bukan database, melainkan jalur baca yang stale.

4. Kirim webhook duplikat dan out-of-order

Kirim callback yang sama dua kali, lalu callback lama setelah callback baru. Jika state sistem berubah mundur, berarti idempotensi dan ordering belum aman.

Perbaikan desain yang lebih andal

1. Satukan perubahan state utama dan audit trail dalam satu transaksi

Jika audit log merupakan bagian dari konsistensi bisnis, jangan tulis di luar transaksi utama. Minimal, update vendor dan insert audit log harus berhasil atau gagal bersama.

// Pseudocode perbaikan
function blacklistVendor(vendorId, actorId, reason) {
  db.beginTransaction();
  try {
    const before = db.queryOne("SELECT status FROM vendors WHERE id = ? FOR UPDATE", [vendorId]);

    db.execute(
      "UPDATE vendors SET status = ?, blacklisted_at = NOW(), blacklist_reason = ?, updated_at = NOW() WHERE id = ?",
      ["blacklisted", reason, vendorId]
    );

    db.execute(
      "INSERT INTO audit_logs(entity_type, entity_id, action, actor_id, payload, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
      [
        "vendor",
        vendorId,
        "vendor_blacklisted",
        actorId,
        JSON.stringify({ before_status: before.status, after_status: "blacklisted", reason })
      ]
    );

    db.execute(
      "INSERT INTO outbox_events(aggregate_type, aggregate_id, event_type, payload, status, created_at) VALUES (?, ?, ?, ?, ?, NOW())",
      ["vendor", vendorId, "vendor.blacklisted", JSON.stringify({ vendorId, reason }), "pending"]
    );

    db.commit();
  } catch (err) {
    db.rollback();
    throw err;
  }
}

Kenapa ini bekerja:

  • Perubahan status vendor tidak akan committed tanpa audit log.
  • Event untuk sinkronisasi ikut tercatat secara atomik dalam transaksi yang sama.
  • Worker tidak lagi bergantung pada event yang dipublish terlalu dini.

2. Gunakan pola transactional outbox

Daripada publish langsung ke broker di dalam request, simpan event ke tabel outbox dalam transaksi yang sama. Setelah commit, proses terpisah membaca outbox dan menerbitkan event ke queue atau integrasi eksternal.

Keuntungannya:

  • Menghindari race condition sebelum commit.
  • Mengurangi risiko data utama berhasil tetapi event hilang.
  • Memudahkan retry yang terukur dan dapat diaudit.

Trade-off-nya:

  • Implementasi sedikit lebih kompleks.
  • Ada latensi tambahan kecil karena event dikirim asynchronous setelah dicatat.
  • Butuh pembersihan outbox dan observabilitas yang baik.

3. Pastikan worker sinkronisasi membaca source of truth yang tepat

Untuk operasi sensitif seperti blacklist, worker sinkronisasi sebaiknya:

  • Membaca dari database utama atau read model yang sudah dijamin konsisten.
  • Tidak mengandalkan cache yang belum diinvalidasi.
  • Menggunakan event payload minimal sebagai petunjuk, bukan satu-satunya sumber kebenaran.

Jika cache tetap dipakai, invalidasi harus terjadi setelah commit dan sebelum jalur baca lain mengonsumsi nilai lama. Banyak tim memilih strategi cache-aside dengan penghapusan key setelah commit agar pembacaan berikutnya memuat ulang data terbaru.

4. Tambahkan idempotensi untuk webhook dan sinkronisasi

Setiap callback atau event eksternal sebaiknya memiliki kunci idempotensi yang disimpan. Jika event yang sama datang ulang, sistem cukup mengembalikan sukses tanpa memproses ulang.

// Pseudocode webhook idempoten
function handleComplianceWebhook(eventId, vendorId, externalStatus, occurredAt) {
  db.beginTransaction();
  try {
    const seen = db.queryOne("SELECT event_id FROM processed_webhooks WHERE event_id = ?", [eventId]);
    if (seen) {
      db.commit();
      return;
    }

    const current = db.queryOne(
      "SELECT compliance_status, last_external_event_at FROM vendor_compliance_status WHERE vendor_id = ? FOR UPDATE",
      [vendorId]
    );

    if (!current || occurredAt >= current.last_external_event_at) {
      db.execute(
        "UPDATE vendor_compliance_status SET compliance_status = ?, last_external_event_at = ?, updated_at = NOW() WHERE vendor_id = ?",
        [externalStatus, occurredAt, vendorId]
      );
    }

    db.execute(
      "INSERT INTO processed_webhooks(event_id, vendor_id, created_at) VALUES (?, ?, NOW())",
      [eventId, vendorId]
    );

    db.commit();
  } catch (err) {
    db.rollback();
    throw err;
  }
}

Poin pentingnya bukan nama tabelnya, tetapi prinsipnya:

  • Jangan proses event yang sama dua kali.
  • Jangan biarkan event lama menimpa state yang lebih baru.
  • Kunci update record yang sama saat memproses callback.

5. Perjelas kontrak sukses API

Jika blacklist vendor dianggap operasi kritikal, definisikan dengan tegas arti respons sukses:

  • Sukses sinkron penuh: request baru dianggap berhasil jika update vendor, audit log, dan pencatatan event outbox selesai.
  • Sukses eventual: request dianggap berhasil jika perubahan utama dan outbox berhasil, sementara sinkronisasi eksternal dilakukan asynchronous dan dapat dipantau.

Yang perlu dihindari adalah kontrak ambigu: API mengembalikan 200 padahal audit log gagal dan tidak ada mekanisme retry yang pasti.

Contoh alur perbaikan end-to-end

Setelah perbaikan, alur yang lebih aman biasanya seperti ini:

  1. Request blacklist masuk dengan request_id.
  2. Backend membuka transaksi.
  3. Baris vendor dikunci bila perlu untuk mencegah update bersamaan.
  4. Status vendor diubah menjadi blacklisted.
  5. Audit log ditulis dalam transaksi yang sama.
  6. Event vendor.blacklisted dimasukkan ke outbox dalam transaksi yang sama.
  7. Transaksi di-commit.
  8. Proses outbox menerbitkan event ke queue.
  9. Worker sinkronisasi membaca source of truth terbaru, bukan cache stale.
  10. Callback eksternal diproses secara idempoten dan tidak boleh menimpa event yang lebih baru.

Dengan pendekatan ini, sistem masih asynchronous, tetapi batas konsistensinya jelas dan bisa diaudit.

Testing yang sebaiknya ditambahkan

1. Integration test untuk atomicity

Uji bahwa jika insert audit log gagal, update vendor ikut gagal. Ini mencegah sukses parsial di jalur kritikal.

2. Test race condition dengan queue

Mock worker agar mengeksekusi job sangat cepat, lalu verifikasi bahwa event tidak diproses sebelum commit jika memakai outbox.

3. Test invalidasi cache

Pastikan setelah blacklist, pembacaan vendor berikutnya tidak mengembalikan status lama dari cache.

4. Test idempotensi webhook

Kirim event yang sama dua kali dan event lama setelah event baru. State final harus tetap benar dan audit tidak duplikat.

5. Observability test

Pastikan log, metric, dan trace memuat:

  • request_id
  • vendor_id
  • event_id
  • hasil commit transaksi
  • status outbox publish
  • hasil sinkronisasi eksternal

Kesalahan umum saat memperbaiki bug ini

  • Hanya menambah retry tanpa memperbaiki batas transaksi. Retry tidak menyelesaikan partial commit yang sudah telanjur terjadi.
  • Mengandalkan cache untuk workflow kritikal. Cache cocok untuk baca cepat, bukan sumber kebenaran untuk keputusan kepatuhan.
  • Menganggap queue menjamin urutan global. Banyak sistem queue hanya memberi urutan terbatas, bukan antar semua worker dan semua retry.
  • Menelan exception audit log demi menjaga API tetap responsif. Untuk event sensitif, ini justru menciptakan jejak audit yang tidak dapat dipercaya.
  • Tidak menyimpan metadata event eksternal seperti event_id dan occurred_at.

Checklist pencegahan sebelum fitur blacklist vendor dirilis

  • Apakah update vendor dan audit log berada dalam transaksi yang sama?
  • Apakah event sinkronisasi dicatat secara atomik, misalnya dengan outbox?
  • Apakah worker membaca source of truth yang sudah committed?
  • Apakah cache diinvalidasi setelah commit, bukan sebelumnya?
  • Apakah webhook inbound punya idempotency key dan proteksi terhadap event out-of-order?
  • Apakah ada integration test untuk partial success, race condition, dan duplicate delivery?
  • Apakah dashboard operasional bisa menunjukkan event outbox yang tertunda atau gagal?
  • Apakah log memiliki request_id, vendor_id, dan event_id agar investigasi cepat?
  • Apakah kontrak API jelas tentang apa yang berarti sukses?

Penutup

Kasus debugging audit log gagal usai blacklist vendor di backend hampir selalu berkaitan dengan konsistensi lintas komponen, bukan sekadar bug pada satu fungsi. Begitu fitur blacklist menyentuh database utama, audit trail, queue, cache, dan webhook, Anda perlu mendesain alur yang tahan terhadap commit parsial, race condition, stale read, dan pengiriman ulang event.

Perbaikan paling berdampak biasanya sederhana secara konsep: satukan perubahan penting dalam satu transaksi, kirim event lewat transactional outbox, kurangi ketergantungan workflow kritikal pada cache, dan terapkan idempotensi kuat pada webhook. Dengan itu, status vendor, audit log, dan sinkronisasi kepatuhan akan bergerak sebagai satu sistem yang dapat dipertanggungjawabkan, bukan kumpulan operasi yang kebetulan sering berhasil.