Debug backend NPC state sering terasa membingungkan ketika sistem tampak normal dari luar: CPU tidak tinggi, error rate rendah, log aplikasi terlihat sehat, tetapi antrean terus menumpuk dan job tidak pernah benar-benar selesai. Dalam pola seperti ini, akar masalahnya sering bukan performa murni, melainkan state machine atau orkestrasi event yang berhenti di satu transisi karena sebuah flag salah atau tidak pernah di-reset.

Kasus ini umum pada backend yang memodelkan entitas seperti NPC, workflow, order, session, atau automation sebagai sekumpulan state sederhana. Pendekatan ini mirip ide low-tech AI pada game: perilaku kompleks dibangun dari state dan transisi yang relatif sederhana, bukan dari logika cerdas yang besar dan abstrak. Referensi seperti pembahasan low-tech AI pada game dapat membantu memberi intuisi, tetapi artikel ini fokus sepenuhnya pada debugging sistem backend.

Gejala Nyata: Event Berulang, Job Tidak Maju, CPU Normal

Bug jenis ini jarang muncul sebagai exception yang jelas. Justru yang terlihat biasanya adalah kombinasi gejala berikut:

  • Job queue terus bertambah, tetapi worker tetap hidup dan tidak crash.
  • CPU, memory, dan network relatif normal sehingga tidak tampak seperti bottleneck infrastruktur.
  • Log berisi pesan sukses seperti received event, processing started, atau state checked, sehingga sekilas terlihat sehat.
  • Event yang sama diproses berulang untuk entity yang sama.
  • Status bisnis tidak pernah maju, misalnya NPC tetap di state engaging, order tetap di processing, atau workflow tetap di awaiting_transition.

Ini berbahaya karena sistem terlihat aktif, bukan rusak total. Akibatnya, monitoring berbasis availability sering tidak menangkap masalah dengan cepat. Yang rusak adalah kemampuan state untuk bergerak maju.

Studi Kasus: Flag transition_in_progress Tidak Pernah Di-reset

Bayangkan backend game server atau simulation service yang memproses NPC behavior melalui event queue. Setiap NPC memiliki state utama dan sebuah flag untuk mencegah transisi ganda:

npc_state = "idle" | "patrol" | "engage" | "cooldown
gtransition_in_progress = true | false

Alurnya kira-kira seperti ini:

  1. Worker menerima event enemy_detected.
  2. Sistem mengecek apakah NPC boleh berpindah dari patrol ke engage.
  3. Sebelum memproses transisi, sistem mengeset transition_in_progress = true.
  4. Worker memanggil beberapa aksi turunan: update state, publish event lanjutan, simpan timestamp, dan mungkin enqueue job berikutnya.
  5. Jika semua sukses, flag di-reset menjadi false.

Bug terjadi ketika ada jalur eksekusi yang keluar lebih awal sebelum reset dilakukan. Misalnya:

  • Ada early return karena kondisi tambahan tidak terpenuhi.
  • Salah satu update gagal diam-diam dan ditangani tanpa rollback.
  • Publish event kedua di-skip karena deduplikasi, tetapi cleanup tidak ikut jalan.
  • Timeout atau retry terjadi di tengah transisi.

Akibatnya, record NPC tersisa di kondisi seperti:

state = "patrol
gtransition_in_progress = true
last_event = "enemy_detected"

Worker berikutnya datang, melihat flag masih aktif, lalu memilih menunggu atau mengulang event yang sama. Sistem tidak benar-benar crash, tetapi entity itu terkunci dalam transisi semu.

Mengapa Log Tampak Sehat Padahal Sistem Macet?

Banyak tim terlalu bergantung pada log level info. Dalam bug ini, log sering menipu karena yang dicatat hanya tahap masuk, bukan transisi lengkap. Contohnya:

[INFO] received event=enemy_detected npc_id=412
[INFO] transition check passed npc_id=412
[INFO] worker finished npc_id=412

Log di atas tampak normal, tetapi tidak menjawab pertanyaan penting:

  • State awal NPC apa?
  • Flag transisi sebelum proses bernilai apa?
  • Apakah state benar-benar berubah?
  • Apakah flag dibersihkan setelah commit?
  • Apakah event lanjutan benar-benar diterbitkan?

Kalau instrumentasi hanya mencatat bahwa handler dipanggil, Anda bisa salah mengira sistem sehat. Padahal yang dibutuhkan adalah observabilitas terhadap perubahan state, bukan sekadar observabilitas terhadap eksekusi fungsi.

Cara Reproduksi Bug Secara Konsisten

Sebelum memperbaiki, usahakan bug bisa direproduksi di staging atau lokal. Reproduksi yang baik mempercepat validasi root cause dan mengurangi tebak-tebakan.

1. Siapkan state awal yang relevan

Buat satu entity dengan state yang biasa memicu transisi:

INSERT INTO npc_runtime (npc_id, state, transition_in_progress, last_event)
VALUES (412, 'patrol', false, NULL);

2. Picu event yang masuk ke jalur bermasalah

Misalnya event enemy_detected memicu validasi tambahan terhadap target. Jika target tidak valid, handler keluar lebih awal:

handleEnemyDetected(npc) {
  markTransitionInProgress(npc.id)

  target = loadTarget(npc.id)
  if (!target || !target.isVisible) {
    return
  }

  updateState(npc.id, "engage")
  resetTransitionFlag(npc.id)
}

Di sini masalahnya jelas: jika target tidak ada atau tidak terlihat, fungsi return sebelum resetTransitionFlag().

3. Jalankan event yang sama beberapa kali

Event berikutnya akan terus gagal maju karena entity masih dianggap sedang transisi. Ini biasanya memunculkan pola retry atau requeue yang terlihat seperti sistem sibuk tetapi tidak produktif.

4. Verifikasi dengan query database

SELECT npc_id, state, transition_in_progress, last_event, updated_at
FROM npc_runtime
WHERE npc_id = 412;

Kalau state tidak berubah tetapi flag tetap aktif dalam durasi yang tidak wajar, Anda sudah dekat ke akar masalah.

Teknik Isolasi Bug yang Efektif

Berikut pendekatan yang paling membantu untuk membongkar bug state machine/event orchestration yang macet.

Logging transisi state, bukan hanya logging handler

Ubah format log dari sekadar “mulai” dan “selesai” menjadi log yang eksplisit tentang state.

{
  "msg": "state_transition_attempt",
  "correlation_id": "req-9f2c",
  "npc_id": 412,
  "event": "enemy_detected",
  "from_state": "patrol",
  "to_state": "engage",
  "transition_flag_before": false
}

{
  "msg": "state_transition_result",
  "correlation_id": "req-9f2c",
  "npc_id": 412,
  "event": "enemy_detected",
  "state_after": "patrol",
  "transition_flag_after": true,
  "outcome": "aborted_target_not_visible"
}

Dengan pola ini, bug yang tadinya tidak terlihat menjadi jelas: transisi dicoba, dibatalkan, tetapi flag tetap aktif.

Correlation ID end-to-end

Gunakan correlation ID yang dibawa dari request atau event producer sampai worker queue. Ini penting karena bug state sering melibatkan lebih dari satu komponen:

  • API menerima trigger.
  • Producer menulis event ke queue.
  • Worker memproses event.
  • Worker kedua memproses event turunan atau retry.

Tanpa correlation ID, Anda akan melihat potongan-potongan log yang tampak normal namun tidak terhubung. Dengan correlation ID, Anda bisa membangun urutan kejadian yang sebenarnya.

Query database untuk mencari entity tersangkut

Jangan hanya memeriksa satu record. Cari pola global:

SELECT state, transition_in_progress, COUNT(*)
FROM npc_runtime
GROUP BY state, transition_in_progress
ORDER BY COUNT(*) DESC;

Jika banyak entity berada di kombinasi yang tidak masuk akal, misalnya state='patrol' dan transition_in_progress=true selama lama, kemungkinan ada jalur kode yang sistematis salah.

Anda juga bisa mencari entitas yang terlalu lama tidak bergerak:

SELECT npc_id, state, transition_in_progress, updated_at
FROM npc_runtime
WHERE transition_in_progress = true
  AND updated_at < NOW() - INTERVAL '10 minutes'
ORDER BY updated_at ASC;

Sesuaikan sintaks interval dengan database yang Anda pakai. Intinya, cari flag aktif yang bertahan melebihi durasi normal transisi.

Timeline request-worker

Buat timeline sederhana dari satu correlation ID:

  1. API menerima trigger enemy_detected.
  2. Producer menulis job ke queue.
  3. Worker A mengambil job dan set transition_in_progress=true.
  4. Worker A menemukan target tidak valid lalu keluar.
  5. Job retry atau event baru datang.
  6. Worker B membaca NPC yang masih transition_in_progress=true dan menolak transisi.
  7. Antrean bertambah, tetapi tiap job tampak “diproses”.

Timeline seperti ini sangat membantu saat menjelaskan bug ke tim lain, terutama jika ada batas antara tim API, queue, dan database.

Root Cause yang Sering Terjadi

Pada inti masalah, bug ini biasanya muncul karena desain transisi tersebar di banyak cabang logika tanpa satu titik yang menjamin cleanup. Beberapa pola akar masalah yang sering ditemui:

  • Flag mutable tanpa lifecycle jelas: flag diset di satu tempat, di-reset di tempat lain, dan tidak semua jalur error melewati reset.
  • State implisit: arti kombinasi field tidak didokumentasikan, sehingga state='patrol' plus transition_in_progress=true bisa berarti banyak hal.
  • Operasi non-atomik: set flag, update state, dan publish event dilakukan terpisah tanpa transaksi atau kompensasi yang jelas.
  • Retry tanpa idempotensi: worker mengulang event yang sama, tetapi tidak punya aturan pasti apakah harus melanjutkan, mengabaikan, atau memulihkan state.
  • Monitoring salah sasaran: metrik hanya memantau resource dan error, bukan usia state atau jumlah entity yang tersangkut.

Langkah Perbaikan yang Disarankan

1. Gunakan desain state yang eksplisit

Jika memungkinkan, kurangi ketergantungan pada flag tambahan yang ambigu. Daripada:

state = 'patrol'
transition_in_progress = true

lebih aman bila state transisional dibuat eksplisit:

state = 'transitioning_to_engage'

Keuntungannya:

  • Semantik lebih jelas.
  • Query debugging lebih mudah.
  • Invariant lebih sederhana.
  • Lebih sulit menghasilkan kombinasi state yang tidak valid.

Trade-off-nya, jumlah state bertambah. Namun untuk sistem event-driven, state yang eksplisit biasanya lebih mudah dioperasikan daripada flag tambahan yang maknanya berubah-ubah.

2. Tetapkan invariant yang bisa dicek

Definisikan aturan yang harus selalu benar. Contoh:

  • Entity tidak boleh berada di state normal sambil membawa flag transisi aktif lebih dari batas waktu tertentu.
  • Setelah transisi sukses, event lanjutan harus tercatat atau state rollback harus jelas.
  • Satu entity hanya boleh memiliki satu transisi aktif pada satu waktu.

Invariant ini bisa divalidasi lewat test, assertion runtime, atau job audit periodik.

3. Pakai guard clause dengan cleanup yang aman

Guard clause tetap berguna, tetapi cleanup jangan bergantung pada setiap cabang return. Bungkus perubahan state dalam pola yang menjamin pelepasan flag atau perpindahan ke state gagal yang sah.

function handleEnemyDetected(npcId, correlationId) {
  beginTransition(npcId, "enemy_detected")

  try {
    const npc = loadNpcForUpdate(npcId)
    const target = loadTarget(npcId)

    if (!target || !target.isVisible) {
      markTransitionAborted(npcId, "target_not_visible")
      return
    }

    moveState(npcId, "engage")
    publishNextEvent(npcId, "engage_started", correlationId)
    completeTransition(npcId)
  } catch (err) {
    failTransition(npcId, err)
    throw err
  }
}

Poin pentingnya bukan nama fungsi, melainkan fakta bahwa semua jalur hasil punya penanganan eksplisit: sukses, batal, atau gagal.

4. Tambahkan timeout atau recovery untuk transisi yang menggantung

Karena sistem distributed tidak pernah sepenuhnya bebas gangguan, siapkan mekanisme pemulihan. Misalnya:

  • Jika state transisional bertahan terlalu lama, tandai sebagai gagal dan izinkan retry aman.
  • Jalankan sweeper job periodik untuk mendeteksi entity yang tersangkut.
  • Simpan timestamp transition_started_at agar timeout berbasis data, bukan perkiraan.

Ini bukan pengganti perbaikan root cause, tetapi sangat penting sebagai pagar operasional.

5. Pastikan handler idempotent

Dalam sistem queue, retry itu normal. Karena itu, handler harus aman bila event yang sama diproses lebih dari sekali.

Contoh prinsip idempotensi:

  • Jika entity sudah berada di state target, jangan memulai transisi baru.
  • Jika event dengan ID yang sama sudah diterapkan, tandai sebagai duplikat dan keluar dengan aman.
  • Gunakan kunci idempotensi untuk event yang bisa datang ulang dari producer atau retry worker.

Tanpa idempotensi, bug flag macet biasanya membesar menjadi event storm kecil yang sulit dibedakan dari lonjakan trafik biasa.

6. Lakukan migration untuk data yang sudah rusak

Setelah kode diperbaiki, jangan lupa bahwa database produksi mungkin sudah berisi entity yang tersangkut. Buat migrasi atau script remedi yang aman.

UPDATE npc_runtime
SET transition_in_progress = false,
    last_event = 'recovered_by_migration'
WHERE transition_in_progress = true
  AND state IN ('patrol', 'idle')
  AND updated_at < NOW() - INTERVAL '30 minutes';

Namun jangan lakukan reset massal secara buta. Selalu:

  • Backup atau snapshot data terkait.
  • Verifikasi invariant lebih dulu.
  • Batasi dengan kondisi yang konservatif.
  • Uji di subset kecil sebelum rollout penuh.

Contoh Perbaikan Desain yang Lebih Tahan Bug

Daripada menyebarkan manipulasi flag ke banyak tempat, pusatkan transisi di satu modul.

const allowedTransitions = {
  idle: ["patrol"],
  patrol: ["engage"],
  engage: ["cooldown"],
  cooldown: ["patrol"]
}

function transitionNpc(npc, nextState, context) {
  if (!allowedTransitions[npc.state]?.includes(nextState)) {
    throw new Error("invalid_transition")
  }

  persistTransition({
    npcId: npc.id,
    fromState: npc.state,
    toState: nextState,
    correlationId: context.correlationId,
    event: context.event
  })
}

Pendekatan ini bekerja karena aturan transisi tidak lagi tersebar di handler event. Handler hanya memutuskan niat bisnis, sedangkan modul transisi memegang validasi, pencatatan, dan perubahan state.

Trade-off-nya, modul transisi menjadi komponen penting yang harus dirancang hati-hati. Tetapi sentralisasi seperti ini sangat membantu saat debugging karena semua perubahan state melewati satu jalur yang bisa diobservasi.

Checklist Pencegahan

  • Apakah semua state dan transisi punya definisi eksplisit?
  • Apakah ada kombinasi field yang ambigu, misalnya state normal plus flag transisi aktif?
  • Apakah semua jalur return, error, timeout, dan retry punya hasil state yang jelas?
  • Apakah log mencatat from state, to state, hasil transisi, dan correlation ID?
  • Apakah ada query atau dashboard untuk mendeteksi entity tersangkut?
  • Apakah event handler aman dijalankan ulang?
  • Apakah ada sweeper atau timeout untuk transisi yang tidak selesai?
  • Apakah perbaikan kode disertai remedi data yang sudah terlanjur korup?
  • Apakah transisi penting dilakukan secara atomik atau dengan strategi kompensasi yang jelas?

Penutup

Debug backend NPC state bukan hanya soal membaca log error, karena pada bug ini justru hampir tidak ada error yang mencolok. Masalah utamanya adalah state machine berhenti bergerak akibat flag transisi yang tidak pernah kembali ke kondisi valid. Dampaknya halus tetapi serius: event berulang, job tidak maju, antrean menumpuk, dan tim mudah salah fokus ke performa atau kapasitas worker.

Pendekatan yang paling efektif adalah memandang sistem sebagai kumpulan state dan transisi yang harus bisa dijelaskan secara eksplisit. Begitu Anda menambahkan logging transisi, correlation ID, query untuk entity tersangkut, dan timeline request-worker, akar masalah biasanya cepat terlihat. Setelah itu, perbaiki bukan hanya bug baris kodenya, tetapi juga desain state, invariant, timeout, idempotensi, dan data yang sudah terlanjur rusak.