Debug race condition pada job moderasi konten berbasis AI biasanya menjadi rumit karena gejalanya tampak acak: status review berubah-ubah, hasil moderasi tertimpa oleh request lain, callback dari layanan AI masuk dua kali, dan log terlihat saling bertentangan. Dalam banyak kasus, masalahnya bukan pada model AI itu sendiri, melainkan pada pipeline backend yang tidak tahan terhadap retry, eksekusi paralel, dan update database yang tidak atomik.

Jika sistem Anda memanggil layanan AI untuk memoderasi teks, audio, gambar, atau metadata, maka guardrail dan kebijakan AI harus diterjemahkan ke mekanisme teknis yang konsisten. Prinsip seperti yang ditekankan pada kebijakan AI milik TIDAL—bahwa penggunaan AI perlu berada dalam batas kebijakan yang jelas dan dapat diaudit—tidak cukup berhenti di level policy. Di backend, itu berarti hasil moderasi harus memiliki jejak eksekusi yang dapat ditelusuri, tidak boleh tertimpa sembarangan, dan harus konsisten walau ada retry, timeout, atau worker paralel.

Studi kasus: pipeline moderasi yang terlihat normal tetapi hasilnya inkonsisten

Bayangkan sebuah alur backend seperti ini:

  1. User mengunggah konten.
  2. API membuat record content_review dengan status awal pending.
  3. Sebuah job dimasukkan ke queue untuk memanggil layanan moderasi AI.
  4. Layanan AI mengembalikan hasil secara sinkron atau melalui callback/webhook.
  5. Worker menyimpan hasil ke database dan mengubah status menjadi approved, rejected, atau manual_review.

Secara desain tampak sederhana. Namun gejala berikut mulai muncul di production:

  • Status review loncat: dari pending ke approved, lalu kembali ke processing, lalu menjadi manual_review.
  • Hasil moderasi tertimpa: skor atau label dari callback pertama diganti callback kedua yang sebenarnya hasil retry lama.
  • Callback ganda: provider AI mengirim ulang callback karena timeout atau respons 5xx.
  • Log tampak acak: urutan log tidak cocok dengan urutan status di database.

Ini adalah pola klasik race condition pada sistem asynchronous. Debugging-nya sulit karena setiap komponen tampak “benar” jika dilihat terpisah: queue bekerja, worker memproses, provider AI merespons, dan database menerima update. Yang rusak adalah koordinasi antarbagian.

Alur request/job yang memicu race condition

Contoh alur bermasalah

Berikut skenario yang sering terjadi:

  1. API membuat job moderasi untuk konten content_id=123.
  2. Worker A mengambil job dan memanggil layanan AI.
  3. Request ke AI timeout di sisi worker, padahal provider sebenarnya tetap memproses.
  4. Karena policy retry aktif, job dijalankan ulang oleh Worker B.
  5. Provider AI akhirnya mengirim callback untuk request pertama.
  6. Worker B juga menerima hasil untuk request kedua.
  7. Kedua hasil menulis ke row yang sama tanpa idempotency check dan tanpa guard pada transisi status.

Jika update dilakukan seperti UPDATE reviews SET status='approved', score=0.1 WHERE content_id=123, maka penulisan terakhir akan menang. Inilah penyebab hasil moderasi tertimpa.

Mengapa log terlihat acak

Urutan log sering membingungkan karena beberapa hal berikut:

  • Timestamp dari beberapa service tidak sinkron penuh.
  • Event dikirim asynchronous ke sistem log.
  • Worker paralel menulis log hampir bersamaan.
  • Satu request logical dapat memiliki beberapa attempt fisik.

Akibatnya, log “callback diterima” bisa muncul sebelum log “request timeout” jika dilihat dari agregator, padahal secara kausal tidak demikian. Karena itu, jangan mengandalkan urutan log mentah saja; gunakan correlation ID, attempt number, dan idempotency key.

Root cause teknis

1. Retry tanpa idempotency key

Retry adalah hal wajar untuk jaringan atau provider AI. Masalah muncul saat setiap retry dianggap request baru. Tanpa idempotency key, backend tidak bisa membedakan:

  • hasil baru yang valid,
  • hasil lama yang datang terlambat,
  • callback duplikat dari request yang sama.

Akibatnya, sistem memproses event yang seharusnya diabaikan.

2. Update database non-atomik

Pola rentan yang sering ditemui:

  1. Ambil row review.
  2. Cek status di aplikasi.
  3. Panggil AI atau baca callback.
  4. Update status dengan query terpisah.

Jika dua worker melakukan ini bersamaan, keduanya bisa membaca state lama yang sama lalu menulis state baru yang saling menimpa. Ini adalah masalah check-then-act tanpa proteksi konkurensi.

3. Worker paralel pada entitas yang sama

Queue memang dirancang untuk paralelisme, tetapi tidak semua unit kerja aman diproses paralel. Untuk moderasi, satu konten atau satu review sebaiknya memiliki serialisasi per entitas. Tanpa itu, Worker A dan Worker B bisa sama-sama menganggap dirinya pemilik sah proses review yang sama.

Langkah investigasi yang efektif

1. Bentuk timeline per content_id atau review_id

Mulailah dari satu kasus konkret, bukan agregat error umum. Ambil satu review_id yang bermasalah lalu susun timeline dari:

  • request API awal,
  • enqueue job,
  • attempt worker,
  • request ke provider AI,
  • callback/webhook masuk,
  • setiap update status ke database.

Simpan semua event dengan field minimal:

  • review_id
  • content_id
  • job_id
  • attempt
  • provider_request_id
  • idempotency_key
  • previous_status
  • new_status
  • updated_at

Target investigasi bukan hanya menemukan “siapa yang terakhir menulis”, tetapi mengapa write itu diizinkan.

2. Cari transisi status yang tidak valid

Definisikan state machine sederhana. Misalnya:

  • pending -> processing
  • processing -> approved | rejected | manual_review
  • approved, rejected, manual_review adalah terminal

Jika Anda menemukan approved -> processing atau rejected -> approved tanpa workflow eksplisit, itu sinyal kuat adanya race condition atau retry tak terkendali.

3. Audit retry policy end-to-end

Periksa retry di semua layer:

  • client HTTP ke provider AI,
  • queue worker,
  • webhook sender dari provider,
  • reverse proxy atau load balancer,
  • job scheduler internal.

Sering kali bug muncul karena retry bertingkat. Misalnya client HTTP retry 3 kali, queue worker retry 5 kali, provider webhook retry 10 kali. Tanpa idempotency, satu kejadian gagal bisa berubah menjadi belasan eksekusi logis.

4. Verifikasi isolation pada query update

Cari query yang mengubah status langsung tanpa syarat state sebelumnya. Contoh query rawan:

UPDATE content_reviews
SET status = 'approved',
    moderation_label = 'safe',
    updated_at = NOW()
WHERE review_id = :review_id;

Query ini tidak memeriksa apakah row masih berada pada status yang diharapkan, dan tidak membedakan callback baru dengan callback lama.

Perbaikan inti: idempotensi, atomic update, dan serialisasi kerja

1. Tambahkan idempotency key per operasi moderasi

Setiap permintaan ke provider AI harus memiliki identifier unik yang stabil untuk satu operasi logis. Simpan di database sebelum request keluar. Callback dari provider juga harus membawa identifier yang bisa dipetakan kembali ke operasi yang sama.

Skema sederhana:

moderation_requests
- id
- review_id
- idempotency_key   -- unik
- provider_request_id
- attempt_no
- status             -- created, sent, completed, failed
- created_at
- updated_at

Prinsipnya:

  • Jika callback dengan idempotency_key yang sama datang dua kali, proses sekali saja.
  • Jika worker retry, gunakan key yang sama untuk operasi yang sama, bukan membuat request logis baru tanpa alasan.
  • Jika memang ingin membuat operasi baru, tandai secara eksplisit sebagai superseding request.

2. Gunakan update atomik berbasis state saat ini

Alih-alih membaca status lalu mengubahnya secara terpisah, lakukan update bersyarat dalam satu langkah.

UPDATE content_reviews
SET status = 'processing',
    worker_id = :worker_id,
    processing_started_at = NOW(),
    updated_at = NOW()
WHERE review_id = :review_id
  AND status = 'pending';

Jika jumlah row yang ter-update adalah 0, berarti worker kalah balapan atau status sudah berubah. Worker harus berhenti dan tidak melanjutkan proses seolah ia pemilik lock.

Untuk hasil akhir:

UPDATE content_reviews
SET status = :final_status,
    moderation_label = :label,
    moderation_score = :score,
    provider_request_id = :provider_request_id,
    completed_at = NOW(),
    updated_at = NOW()
WHERE review_id = :review_id
  AND status = 'processing'
  AND active_idempotency_key = :idempotency_key;

Di sini, callback lama atau callback dari request lain tidak bisa sembarang menimpa hasil aktif.

3. Simpan callback sebagai event terpisah sebelum mutasi final

Jangan langsung mengubah tabel utama saat webhook masuk. Simpan dulu event mentah ke tabel moderation_callbacks dengan unique constraint, lalu proses secara idempoten.

moderation_callbacks
- id
- provider_event_id      -- unik jika tersedia
- idempotency_key
- payload_json
- received_at
- processed_at

Keuntungannya:

  • payload asli tetap ada untuk audit dan debugging,
  • duplikasi lebih mudah dideteksi,
  • pemrosesan callback bisa diulang aman bila worker crash di tengah jalan.

4. Pakai locking yang tepat, bukan berlebihan

Ada beberapa pilihan:

  • Row-level lock di database saat transisi state kritis.
  • Optimistic locking dengan kolom versi atau pengecekan updated_at.
  • Distributed lock bila worker tersebar dan perlu serialisasi lintas proses.
  • Partitioning queue by key agar semua job untuk review_id tertentu jatuh ke jalur serial yang sama.

Untuk kasus moderasi, kombinasi paling praktis biasanya adalah:

  • idempotency key,
  • atomic conditional update,
  • unique constraint di event callback,
  • opsional row lock saat mengambil kepemilikan proses.

Trade-off: locking terlalu agresif bisa menurunkan throughput dan memicu deadlock jika urutan akses tidak konsisten. Karena itu, gunakan lock sekecil mungkin dan hanya di bagian kritis.

Contoh pseudocode perbaikan

Saat job mulai diproses

function processModerationJob(reviewId, idempotencyKey, workerId) {
  rows = execute(
    "UPDATE content_reviews
     SET status = 'processing',
         active_idempotency_key = ?,
         worker_id = ?,
         updated_at = NOW()
     WHERE review_id = ?
       AND status = 'pending'",
    [idempotencyKey, workerId, reviewId]
  )

  if rows == 0 {
    log("skip: review is not pending anymore", reviewId, idempotencyKey)
    return
  }

  createModerationRequestIfNotExists(reviewId, idempotencyKey)
  callProvider(reviewId, idempotencyKey)
}

Saat callback diterima

function handleCallback(providerEventId, idempotencyKey, result) {
  inserted = insertCallbackIfNotExists(providerEventId, idempotencyKey, result)
  if !inserted {
    log("duplicate callback ignored", providerEventId, idempotencyKey)
    return
  }

  rows = execute(
    "UPDATE content_reviews
     SET status = ?,
         moderation_label = ?,
         moderation_score = ?,
         completed_at = NOW(),
         updated_at = NOW()
     WHERE active_idempotency_key = ?
       AND status = 'processing'",
    [mapResultToStatus(result), result.label, result.score, idempotencyKey]
  )

  if rows == 0 {
    log("stale or invalid callback", idempotencyKey)
  }
}

Pola ini bekerja karena semua sisi memperlakukan idempotencyKey sebagai identitas operasi logis yang sama. Callback ganda akan tersaring. Callback terlambat dari operasi lama juga tidak akan lolos jika active_idempotency_key sudah berubah.

Strategi observability yang benar-benar membantu

1. Correlation ID di seluruh jalur

Setidaknya bawa identifier berikut di log dan tracing:

  • review_id
  • content_id
  • job_id
  • attempt_no
  • idempotency_key
  • provider_request_id
  • provider_event_id

Tanpa ini, Anda hanya melihat potongan log yang tampak acak.

2. Audit log untuk transisi state

Buat event eksplisit setiap kali state berubah:

review_state_transitions
- id
- review_id
- from_status
- to_status
- cause              -- job_started, provider_callback, manual_override, retry_reconcile
- actor              -- worker, webhook, admin
- idempotency_key
- created_at

Ini jauh lebih berguna daripada hanya melihat nilai terakhir pada tabel utama.

3. Metric yang relevan

Beberapa metric yang layak dipantau:

  • jumlah callback duplikat per provider,
  • jumlah conditional update yang gagal karena state mismatch,
  • jumlah transisi status tidak valid,
  • latensi antara enqueue, request provider, callback, dan finalisasi,
  • jumlah job retry per review.

Jika conditional update gagal meningkat tajam, itu sering menandakan kontensi atau desain ownership yang belum benar.

Catatan: untuk sistem moderasi berbasis AI, observability bukan hanya soal performa. Ia juga mendukung kebutuhan audit, akuntabilitas, dan penerapan guardrail kebijakan AI. Saat sebuah konten ditandai atau lolos moderasi, tim harus bisa menjelaskan event mana yang menghasilkan keputusan akhir.

Test regresi untuk memastikan bug tidak kembali

1. Uji callback ganda

Buat test yang mengirim callback yang sama dua kali. Ekspektasinya:

  • hanya satu event callback yang diproses,
  • status final tetap konsisten,
  • tidak ada side effect ganda seperti notifikasi dobel.

2. Uji dua worker memproses review yang sama

Simulasikan dua worker memulai pekerjaan bersamaan. Ekspektasinya:

  • hanya satu worker berhasil mengambil ownership state pending -> processing,
  • worker lain keluar tanpa memanggil provider atau tanpa mengubah state akhir.

3. Uji callback terlambat dari attempt lama

Buat dua attempt untuk review yang sama, lalu kirim hasil attempt lama setelah attempt baru aktif. Ekspektasinya:

  • hasil lama ditandai stale,
  • hasil baru tetap menjadi sumber kebenaran.

4. Uji retry jaringan

Simulasikan timeout saat memanggil provider, lalu anggap provider sebenarnya sukses dan callback tetap datang. Ekspektasinya:

  • retry tidak membuat hasil tertimpa sembarangan,
  • idempotency key tetap mengikat operasi logis yang sama jika memang retry atas operasi yang sama.

5. Uji state machine

Tulis test eksplisit untuk transisi yang valid dan tidak valid. Ini penting agar refactor di masa depan tidak diam-diam membuka jalur status yang tidak semestinya.

Kesalahan umum saat memperbaiki bug ini

  • Hanya menambah retry tanpa idempotensi. Ini justru memperburuk duplikasi.
  • Mengandalkan timestamp terbaru sebagai pemenang. Event yang datang belakangan belum tentu paling benar.
  • Menyamakan job ID dengan identitas operasi bisnis. Job retry bisa punya job ID baru untuk operasi yang sebenarnya sama.
  • Mengunci terlalu lama saat menunggu respons provider AI. Lock sebaiknya tidak menahan transaksi selama panggilan jaringan eksternal.
  • Tidak menyimpan payload callback mentah, sehingga investigasi postmortem kehilangan bukti utama.

Checklist pencegahan

  • Gunakan idempotency key untuk setiap operasi moderasi logis.
  • Terapkan unique constraint pada event callback atau request yang harus unik.
  • Lakukan conditional atomic update berdasarkan state saat ini.
  • Definisikan state machine yang jelas dan tolak transisi ilegal.
  • Pastikan worker paralel tidak bebas memproses entitas yang sama tanpa koordinasi.
  • Simpan audit trail untuk setiap transisi status.
  • Instrumentasi log, trace, dan metric dengan correlation ID yang lengkap.
  • Uji skenario retry, callback duplikat, callback terlambat, dan competing worker.
  • Pisahkan penyimpanan event mentah dari mutasi tabel utama.
  • Hubungkan implementasi teknis dengan kebijakan AI internal agar keputusan moderasi dapat dijelaskan dan diaudit.

Penutup

Race condition pada pipeline moderasi konten berbasis AI hampir selalu tampak seperti bug acak, padahal polanya berulang: retry tanpa idempotency key, update non-atomik, dan worker paralel pada entitas yang sama. Solusi yang efektif bukan sekadar menambah log atau menurunkan concurrency, melainkan memperjelas identitas operasi, memperketat transisi status, dan membuat callback aman diproses berulang.

Jika sistem moderasi Anda juga membawa implikasi kebijakan dan guardrail AI, seperti yang ditekankan dalam praktik AI policy modern, maka konsistensi backend bukan detail kecil. Ia adalah fondasi agar keputusan moderasi bisa dipercaya, diaudit, dan tidak berubah hanya karena urutan event di jaringan kebetulan berbeda.