Menjawab Kegagalan Job Background API yang Tidak Deterministik
Ketika sebuah background job worker kembali gagal secara sporadis, penyebab paling umum adalah race condition pada resource bersama seperti cache status atau indikator progress. Dalam kasus ini, API job background kami gagal mengeksekusi setelah menerima status sukses karena dua worker memodifikasi data job secara bersamaan. Solusi langsungnya melibatkan identifikasi race condition, penambahan mekanisme sinkronisasi, dan validasi hasil di staging sebelum deploy ke produksi.
Gejala Kegagalan dan Observasi Awal
Gejalanya adalah API job background yang memproses antrian Redis kembali 500 setelah berhasil memasukkan job ke queue. Responsnya bilang job sudah selesai, tapi status sistem tetap “pending”. Begitu pun pengguna melihat job tidak selesai meski worker terlihat tidak error. Untuk menyelidiki, kami melakukan langkah berikut:
- Log timestamp: Tinjau log worker dan API, lihat apakah job yang sama diproses lebih dari sekali dalam waktu dekat.
- Tracing request: Gunakan distributed tracing (misal OpenTelemetry) untuk melihat apakah dua request worker memasuki kode yang sama bersamaan.
- Grafana metrics: Pantau metrik sukses/gagal job dan waktu proses, lalu cek apakah spike failure berhubungan dengan lonjakan throughput.
Dari log terlihat dua worker mendapat job dengan ID sama dan menulis status ke Redis hampir bersamaan. Tracing mengonfirmasi kedua worker memanggil markJobCompleted() sebelum status job diperbarui, sementara metrics menunjukkan perbedaan waktu penyelesaian 10-20ms.
Root Cause: Race Condition pada Resource Bersama
Race condition terletak pada aktualisasi status job di Redis yang dilakukan secara langsung tanpa sinkronisasi. Konteksnya:
- Job API menulis status “in-progress” ke Redis lalu men-trigger worker.
- Worker pertama menyelesaikan job dan menulis status “completed”.
- Worker kedua, karena retry atau pendekatan timeout, juga menyelesaikan job dan menulis status “completed” sekaligus menghapus cache terkait.
Masalahnya muncul ketika worker kedua melihat status pertama sebagai “in-progress”, lalu menulis status lain tanpa memeriksa apakah pekerjaan sudah selesai. Tanpa mekanisme atomic, status bisa tergantung urutan eksekusi dan menyebabkan job gagal karena status terakhir tidak konsisten.
Perbaikan Kode dengan Locking dan Idempotensi
Solusi yang terbukti efektif:
- Locking per job ID: Gunakan Redis
SETNX/SETdengan opsiNXuntuk membuat lock semata. Setiap worker mencoba lock sebelum memperbarui status; jika gagal artinya job sedang di-handle, worker menjalankan retry dengan exponential backoff. - Idempotensi operasi: Tambahkan guard di
markJobCompleted()agar hanya menulis status jika status sebelumnya bukan “completed”. Ini memastikan retry tidak mengubah status setelah selesai. - Retry terbatas: Batasi jumlah retry dan catat kejadian lock timeout ke metric agar dapat dipantau.
Contoh simplifikasi:
def mark_job_completed(job_id, result):
lock_key = f"job_lock:{job_id}"
if not redis_client.set(lock_key, "locked", ex=30, nx=True):
return False # worker lain sedang memproses
try:
current_status = redis_client.get(f"job_status:{job_id}")
if current_status == b"completed":
return True
redis_client.set(f"job_status:{job_id}", "completed")
publish_completion_event(job_id, result)
return True
finally:
redis_client.delete(lock_key)
Penting agar publish_completion_event ditulis hanya sekali, karena event idempotent mencegah konsumer downstream memicu duplicate work. Jika cache status hilang, gunakan fallback read dari database.
Validasi Perbaikan di Staging
Setelah kode diubah, langkah validasi:
- Deploy ke environment staging bersamaan dengan simulasi concurrency tinggi: kirim 100 request job dengan timeout dan 10 worker.
- Gunakan tracing untuk memastikan hanya satu worker yang menulis status per job.
- Periksa metric lock timeout dan pastikan jumlahnya rendah serta tidak menyebabkan job gagal.
- Uji fallback: matikan Redis lock sementara untuk melihat apakah sistem error handling berjalan dengan benar.
Hasilnya memperlihatkan nol job dengan status “pending” dan tidak ada error 500. Tracing dan logs menunjukan worker lain langsung masuk ke retry dan keluar cepat tanpa menulis status.
Takeaways Praktis
- Pantau operasi asinkron: Tracking job status, lock wait, dan retries membantu deteksi race condition sebelum terjadi fatal.
- Implementasikan locking minimal: Gunakan Redis lock atau database row lock sewajarnya untuk operasi status bersama.
- Idempotensi kunci: Pastikan setiap operasi background bisa dijalankan lebih dari sekali tanpa efek samping negatif.
- Validasi di staging: Simulasi concurrency tinggi dan periksa metrics lock/debug untuk memastikan perbaikan bekerja.
Dengan pendekatan ini, tidak hanya masalah race condition terselesaikan, tetapi sistem menjadi lebih observabel dan lebih tahan terhadap retry atau duplikasi job.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!