Debugging race condition pada orkestrasi AI agent backend biasanya tidak gagal secara terang-terangan. Sistem tetap terlihat hidup, queue tetap berjalan, dan agent tetap mengembalikan output. Masalahnya baru tampak ketika dua run aktif saling berbagi state sesi, memakai lock yang terlalu kasar atau justru salah scope, lalu menghasilkan eksekusi ganda, hasil agent tertukar, atau policy sandbox tidak lagi diterapkan konsisten.
Dalam artikel ini, kita membahas studi kasus backend orkestrasi multi-agent mirip Omnigent: ada orchestrator, scheduler, worker, tool runner, session store, dan sandbox policy evaluator. Fokusnya bukan teori race condition secara umum, tetapi bagaimana bug ini muncul di sistem nyata, bagaimana log dan trace menyesatkan, mengapa hipotesis awal sering keliru, dan bagaimana memperbaikinya secara bertahap tanpa menambah kompleksitas yang tidak perlu.
Arsitektur singkat dan titik rawan race condition
Bayangkan alur request berikut:
- Client mengirim request untuk menjalankan workflow multi-agent.
- Orchestrator membuat run baru dan memecahnya menjadi beberapa job atau langkah agent.
- Scheduler mendorong job ke queue.
- Worker mengambil job, memuat state sesi, policy sandbox, dan context agent.
- Tool runner mengeksekusi tool atau model call, lalu menyimpan hasil ke state run.
- Orchestrator menggabungkan hasil dan menentukan langkah berikutnya.
Pada desain seperti ini, race condition sering muncul di empat titik:
- State sesi tidak terisolasi per run, misalnya cache key hanya memakai
session_idpadahal satu sesi bisa memiliki beberapa run paralel. - Lock salah scope, misalnya lock memakai
agent_idatauuser_id, bukan kombinasi yang benar sepertirun_id + step_id. - Job queue at-least-once delivery, sehingga worker yang crash atau timeout bisa menyebabkan job dijalankan ulang.
- Policy sandbox dimuat dari state bersama, lalu tertimpa oleh job lain sebelum dieksekusi.
Masalahnya menjadi lebih sulit karena setiap komponen tampak benar jika dilihat terpisah. Race condition biasanya lahir dari interaksi antar komponen, bukan dari satu fungsi yang jelas salah.
Studi kasus: hasil agent tertukar dan eksekusi dobel
Gejala awal di production
Tim backend menerima tiga laporan yang awalnya dianggap tidak berkaitan:
- Satu request menghasilkan dua panggilan tool yang identik, padahal hanya satu yang seharusnya dieksekusi.
- Output dari agent perencana muncul pada run milik agent eksekutor lain.
- Pada kondisi tertentu, tool yang seharusnya dibatasi sandbox justru berjalan dengan policy yang lebih longgar.
Dari sisi pengguna, gejalanya tampak acak. Dari sisi log, masalahnya juga tidak langsung jelas karena semua request punya status 200 atau selesai normal. Tidak ada stack trace fatal.
Contoh potongan log yang menyesatkan
[req=R-901 run=run-A job=job-17 worker=w-2] loading session state session_id=sess-44
[req=R-901 run=run-A job=job-17 worker=w-2] acquired lock key=agent:planner
[req=R-902 run=run-B job=job-03 worker=w-5] loading session state session_id=sess-44
[req=R-902 run=run-B job=job-03 worker=w-5] acquired lock key=agent:planner
[req=R-901 run=run-A job=job-17 worker=w-2] sandbox policy=restricted
[req=R-902 run=run-B job=job-03 worker=w-5] sandbox policy=default
[req=R-901 run=run-A job=job-17 worker=w-2] tool result stored step=planner.output
[req=R-902 run=run-B job=job-03 worker=w-5] tool result stored step=planner.outputJika hanya melihat sekilas, log di atas tampak normal. Dua run berbeda, dua worker berbeda, masing-masing memproses job sendiri. Tetapi ada dua petunjuk penting:
- Keduanya memuat
session_idyang sama. - Keduanya mendapatkan lock dengan key yang sama-sama terlalu umum:
agent:planner.
Akibatnya, state dan sinkronisasi tidak lagi merepresentasikan satu unit kerja yang benar.
Langkah reproduksi yang akhirnya berhasil
Pada awalnya tim sulit mereproduksi bug karena pengujian lokal terlalu lambat dan terlalu serial. Masalah baru muncul saat concurrency cukup tinggi. Langkah reproduksi yang akhirnya konsisten adalah:
- Buat satu
session_idyang sama. - Kirim dua request hampir bersamaan untuk membuat dua
run_idberbeda di sesi tersebut. - Pastikan keduanya memanggil agent yang sama, misalnya
planner. - Tambahkan delay kecil yang tidak deterministik sebelum penulisan state, misalnya pada pembacaan policy atau penyimpanan hasil.
- Jalankan worker lebih dari satu instance agar ada eksekusi paralel nyata.
Contoh pseudo-flow reproduksi:
Client A --(session=sess-44)--> create run-A
Client B --(session=sess-44)--> create run-B
Worker 1: process run-A/planner
Worker 2: process run-B/planner
Keduanya:
- load state by session_id
- load/update sandbox policy
- write planner.output
- schedule next stepJika state disimpan berdasarkan session_id saja, maka run-A dan run-B akan membaca dan menulis objek yang sama. Jika lock juga tidak spesifik ke run, worker bisa salah merasa aman padahal sebenarnya tidak mengisolasi unit kerja yang benar.
Hipotesis awal yang keliru
Pola debugging race condition sering terhambat oleh hipotesis yang masuk akal tetapi salah. Pada kasus ini, ada beberapa dugaan awal:
1. Bug ada di queue karena job diproses dua kali
Ini hanya setengah benar. Banyak sistem queue memang memberi jaminan at-least-once delivery, sehingga job bisa diproses ulang. Namun itu bukan akar masalah utama jika sistem Anda sudah dirancang idempotent. Dalam kasus ini, re-delivery memperparah gejala, tetapi tidak menjelaskan hasil agent tertukar antar run.
2. Tool runner tidak thread-safe
Tim sempat memeriksa klien HTTP, SDK model, dan executor sandbox. Ternyata semuanya relatif aman. Hasil tertukar bukan karena variabel global di tool runner, melainkan karena state orchestration yang dibaca dari key yang salah.
3. Tracing salah mengaitkan span
Trace memang terlihat membingungkan karena span child dari dua run berbeda kadang membawa atribut sesi yang sama. Namun observability yang membingungkan bukan penyebab, hanya gejala dari korelasi yang kurang tepat.
Root cause teknis
Setelah menambahkan logging yang lebih kaya dan membandingkan state sebelum/sesudah tiap write, akar masalahnya ternyata kombinasi dari tiga cacat desain kecil yang bersama-sama berbahaya:
State sesi dipakai sebagai state run
Implementasi awal menyimpan context aktif ke store dengan key seperti:
state:{session_id}Padahal satu sesi bisa memiliki beberapa run paralel. Akibatnya, ketika run-A memuat planner context lalu run-B menulis update, write terakhir menang dan state run sebelumnya tertimpa.
Lock memakai scope agent, bukan run-step
Lock awal menggunakan key seperti:
lock:agent:{agent_name}Ini menciptakan dua masalah:
- Terlalu coarse-grained: run yang tidak terkait ikut saling menunggu.
- Tetap tidak cukup benar: jika ada alur yang mengambil lock setelah state sudah dibaca, maka race masih mungkin terjadi pada fase load/update/write.
Lebih buruk lagi, beberapa path kode ternyata tidak selalu mengambil lock yang sama. Ada path retry yang langsung menulis hasil berdasarkan state yang sudah dibaca sebelumnya.
Policy sandbox dicache pada objek mutable bersama
Untuk mengurangi overhead, policy evaluator meng-cache hasil resolusi policy pada objek session context yang mutable. Karena context itu dipakai lintas run pada sesi yang sama, run dengan policy lebih longgar bisa menimpa policy run lain sebelum tool dieksekusi.
Ini menjelaskan mengapa sesekali sandbox policy tampak terlewati: bukan karena sandbox dimatikan, tetapi karena worker membaca policy dari state bersama yang sudah berubah.
Pseudocode sebelum perbaikan
function processJob(job) {
const stateKey = `state:${job.sessionId}`
const lockKey = `lock:agent:${job.agentName}`
const state = store.get(stateKey)
const lock = locks.acquire(lockKey, { ttl: 30000 })
try {
const policy = resolvePolicy(state.sessionContext, job.agentName)
state.sessionContext.policy = policy
const result = runAgentTool({
agent: job.agentName,
input: state.currentInput,
policy: state.sessionContext.policy
})
state.steps[job.stepName] = result
store.set(stateKey, state)
queueNextJobs(state)
} finally {
lock.release()
}
}Masalah utama pada pseudocode di atas:
stateKeytidak mengandungrunId.- State dibaca sebelum lock benar-benar melindungi unit kerja yang tepat.
policyditulis kesessionContextyang mutable dan dibagi lintas run.- Tidak ada idempotency key untuk menahan eksekusi ulang job yang sama.
Perbaikan bertahap yang aman diterapkan
Perbaikan race condition pada backend orkestrasi sebaiknya dilakukan bertahap. Mengubah semuanya sekaligus sering membuat sistem sulit divalidasi.
1. Isolasi state per run
Perubahan paling penting adalah memisahkan state run dari state sesi. Sesi boleh tetap ada untuk metadata jangka panjang, tetapi progress eksekusi, hasil langkah, policy efektif, dan pointer workflow harus menjadi milik run.
state:{session_id}:run:{run_id}Jika satu run memiliki banyak langkah paralel, Anda bahkan bisa memecah lagi state kerja sementara per langkah, sambil tetap menyimpan snapshot agregat di level run.
2. Ubah lock menjadi spesifik terhadap unit kerja
Lock yang lebih tepat biasanya berbentuk:
lock:run:{run_id}:step:{step_id}atau jika job punya identitas unik:
lock:job:{job_id}Dengan begitu, lock benar-benar mengontrol satu operasi yang harus eksklusif. Ini juga mengurangi kontensi tidak perlu antar run yang berbeda.
3. Tambahkan idempotency key
Karena queue dan jaringan tidak selalu memberi eksekusi tepat satu kali, Anda perlu menahan duplikasi di lapisan aplikasi. Gunakan key yang stabil untuk satu unit hasil, misalnya:
idempotency:{run_id}:{step_id}:{attempt_group}Prinsipnya sederhana: sebelum menjalankan tool atau menulis hasil, cek apakah hasil untuk unit kerja ini sudah pernah dikomit. Jika ya, kembalikan hasil sebelumnya atau abaikan eksekusi ulang.
4. Hindari objek mutable bersama untuk policy
Policy sandbox sebaiknya direpresentasikan sebagai data immutable yang dihitung per run atau per step, lalu dipass secara eksplisit ke executor. Jangan menyimpan policy efektif pada session context bersama jika ada kemungkinan sesi menjalankan banyak run paralel.
5. Gunakan write yang aman terhadap konkurensi
Bila backend Anda menggunakan database atau key-value store, pilih pola update yang konsisten:
- Compare-and-set/version check untuk mendeteksi write berdasarkan state usang.
- Unique constraint untuk mencegah insert hasil job duplikat.
- Transactional update jika beberapa field harus berubah atomik.
Pseudocode sesudah perbaikan
function processJob(job) {
const stateKey = `state:${job.sessionId}:run:${job.runId}`
const lockKey = `lock:run:${job.runId}:step:${job.stepId}`
const idemKey = `idem:${job.runId}:${job.stepId}`
const lock = locks.acquire(lockKey, { ttl: 30000 })
try {
const existing = results.get(idemKey)
if (existing) {
return existing
}
const state = store.get(stateKey)
const effectivePolicy = resolvePolicy({
sessionMeta: state.sessionMeta,
runMeta: state.runMeta,
agentName: job.agentName
})
const result = runAgentTool({
agent: job.agentName,
input: state.inputs[job.stepId],
policy: effectivePolicy
})
store.transaction(() => {
const fresh = store.get(stateKey)
if (fresh.completedSteps[job.stepId]) {
return
}
fresh.completedSteps[job.stepId] = {
result,
policyHash: hash(effectivePolicy)
}
store.set(stateKey, fresh)
results.set(idemKey, result)
enqueueNextSteps(fresh, job.stepId)
})
return result
} finally {
lock.release()
}
}Pseudocode ini tidak menyelesaikan semua variasi race condition, tetapi sudah menutup tiga lubang terbesar: state terisolasi, lock tepat scope, dan duplikasi hasil tertahan oleh idempotency.
Strategi observability untuk membedakan gejala dan penyebab
Race condition sulit dilacak jika log tidak membawa identitas yang cukup. Minimal, setiap log dan span perlu memuat:
request_idsession_idrun_idjob_idstep_idworker_idlock_keyidempotency_keypolicy_hashstate_version
Yang penting bukan sekadar banyaknya log, tetapi apakah log bisa menjawab pertanyaan berikut:
- Job ini dieksekusi berapa kali?
- Eksekusi ganda terjadi sebelum atau sesudah hasil pertama dikomit?
- Dua worker membaca state versi yang sama atau berbeda?
- Policy yang dipakai saat eksekusi sama dengan policy yang disimpan di hasil?
- Lock apa yang benar-benar diambil pada setiap path kode?
Contoh log yang lebih membantu
{
"event": "job.start",
"session_id": "sess-44",
"run_id": "run-A",
"job_id": "job-17",
"step_id": "planner-1",
"worker_id": "w-2",
"lock_key": "lock:run:run-A:step:planner-1",
"state_version": 12
}
{
"event": "policy.resolved",
"run_id": "run-A",
"step_id": "planner-1",
"policy_hash": "9f2c..."
}
{
"event": "idempotency.hit",
"run_id": "run-A",
"step_id": "planner-1",
"idempotency_key": "idem:run-A:planner-1"
}Trace terdistribusi juga berguna, terutama untuk melihat dua worker memproses unit kerja yang sama atau saling tumpang tindih. Namun trace hanya efektif jika atribut identitas konsisten. Banyak tim hanya menaruh request_id, padahal di orkestrasi async, run_id dan job_id jauh lebih penting.
Distributed lock: kapan membantu, kapan tidak cukup
Distributed lock sering dianggap solusi utama race condition, padahal ia hanya salah satu lapisan pertahanan.
Kapan lock membantu
- Mencegah dua worker mengerjakan step yang sama secara paralel.
- Mengurangi risiko double scheduling pada transisi state tertentu.
- Menjaga bagian kritis yang pendek dan jelas.
Kapan lock tidak cukup
- Jika key lock salah scope.
- Jika ada path kode yang tidak selalu mengambil lock.
- Jika TTL lock habis sebelum operasi selesai.
- Jika hasil job tetap bisa ditulis ulang tanpa pengecekan idempotency atau versioning.
Prinsip praktisnya: gunakan lock untuk koordinasi, bukan sebagai satu-satunya jaminan konsistensi. Untuk sistem queue yang dapat mengulang delivery, Anda tetap butuh idempotency dan write yang aman.
Pola alur request yang lebih aman
Berikut alur yang lebih tahan terhadap race condition:
- API menerima request dan membuat
run_idunik. - State run diinisialisasi terpisah dari state sesi.
- Setiap step/job diberi
job_iddanstep_idunik. - Worker mengambil distributed lock per
run_id + step_id. - Worker mengecek idempotency key sebelum eksekusi.
- Policy sandbox dihitung dari data immutable, bukan dari context bersama yang mutable.
- Hasil ditulis dengan transaksi atau version check.
- Step berikutnya dijadwalkan hanya setelah commit berhasil.
- Metric dan trace dipancarkan dengan korelasi penuh.
Alur ini bekerja karena setiap unit kerja memiliki identitas, batas konsistensi, dan jejak observability yang jelas.
Test concurrency untuk mencegah regresi
Bug race condition hampir selalu kembali jika tidak dikunci dengan test yang sengaja memaksa interleaving. Jangan puas dengan unit test serial.
Jenis test yang sebaiknya ada
- Concurrent integration test: dua atau lebih worker memproses step berbeda dan step yang sama.
- Duplicate delivery test: job yang sama dikirim dua kali secara sengaja.
- State isolation test: dua run dalam satu sesi tidak boleh saling membaca hasil satu sama lain.
- Policy isolation test: policy efektif run-A tidak boleh mengubah policy run-B.
- Lock expiry test: simulasi worker lambat atau crash saat lock hampir habis.
Contoh skenario uji
Skenario: dua run dalam satu session
- buat session sess-44
- start run-A dan run-B bersamaan
- keduanya memanggil agent planner
- sisipkan delay acak sebelum commit hasil
- verifikasi:
1) planner.output run-A != planner.output run-B jika input berbeda
2) tidak ada step yang dikomit dua kali
3) policy_hash tiap run sesuai policy yang diharapkan
4) next step hanya dijadwalkan sekali per stepJika memungkinkan, jalankan test ini berkali-kali dengan timing acak. Race condition sering lolos pada satu atau dua kali percobaan, lalu muncul pada iterasi ke-30.
Alerting yang benar-benar berguna
Alerting untuk race condition sebaiknya berbasis sinyal anomali, bukan hanya error rate. Karena banyak race condition berakhir dengan status sukses palsu.
Beberapa sinyal yang layak dipantau:
- Jumlah idempotency hit yang melonjak tajam.
- Satu
step_idmemiliki lebih dari satu commit hasil. - Mismatch antara
policy_hashsaat resolve dan saat result commit. - Proporsi lock timeout atau lock contention meningkat.
- Jumlah next-step enqueue lebih besar dari jumlah step completion unik.
- Run dengan state version mundur atau write conflict meningkat.
Alert seperti ini membantu mendeteksi bug walau pengguna belum melapor.
Kesalahan umum saat memperbaiki race condition
- Menambah lock global agar bug hilang sementara. Ini sering menurunkan throughput dan tidak menyelesaikan akar masalah.
- Mengandalkan retry tanpa idempotency. Retry justru bisa menggandakan efek samping.
- Menganggap cache aman dibagi hanya karena datanya kecil. Mutable shared cache adalah sumber masalah klasik.
- Kurang detail pada korelasi log. Tanpa
run_iddanstep_id, Anda hanya melihat kebisingan. - Tidak menguji dengan paralelisme nyata. Race condition jarang muncul pada mode single worker.
Checklist pencegahan regresi
- State eksekusi disimpan per run, bukan per session.
- Step/job punya identitas unik dan stabil.
- Distributed lock memakai key sesuai unit kerja aktual.
- Semua path eksekusi, termasuk retry dan recovery, mengikuti aturan lock yang sama.
- Idempotency key diperiksa sebelum eksekusi dan sebelum commit hasil.
- Policy sandbox dihitung dari input immutable dan tidak disimpan pada objek bersama yang mutable.
- Write ke store memakai transaction, unique constraint, compare-and-set, atau mekanisme ekuivalen.
- Trace dan log memuat session, run, job, step, lock, policy hash, dan version.
- Ada test concurrency, duplicate delivery, dan isolation test di pipeline CI.
- Ada alert untuk duplicate commit, policy mismatch, lock contention, dan idempotency spike.
Penutup
Pada backend orkestrasi multi-agent, race condition jarang berasal dari satu baris kode yang jelas salah. Ia biasanya lahir dari kombinasi keputusan kecil: key state terlalu umum, lock salah scope, retry tanpa idempotency, dan policy yang disimpan pada shared mutable state. Itulah sebabnya debugging race condition pada orkestrasi AI agent backend harus dimulai dari identitas unit kerja yang tepat, observability yang cukup rinci, dan batas konsistensi yang jelas.
Jika Anda hanya mengambil satu pelajaran dari studi kasus ini, ambillah yang ini: jangan perlakukan session sebagai batas eksekusi. Untuk workflow multi-agent yang paralel, batas yang aman hampir selalu adalah run atau step. Setelah itu, lapisi dengan idempotency, distributed lock yang tepat, dan test concurrency yang benar-benar memaksa interleaving. Di situlah bug yang tampak acak akhirnya menjadi deterministik, bisa dijelaskan, dan bisa diperbaiki.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!