Ringkasan Masalah Job Terjadwal Gagal Deadlock Redis

Job terjadwal Laravel yang dijalankan oleh schedule kadang gagal dan berhenti di status "processing" walaupun tidak ada exception eksplisit. Horizon memberikan log "job timed out" atau "failed to obtain lock", dan koneksi Redis tetap memegang key lock lama, sehingga job yang berikutnya tidak berjalan sampai salah satu proses di-restart.

Artikel ini menjelaskan langsung penyebab deadlock Redis yang muncul di job ini, bagaimana mereproduksi kasusnya, serta langkah verifikasi setelah perbaikan agar issue tidak kembali.

Gejala di Horizon dan Redis

Agregasi Log Horizon

Log Horizon menunjukkan job entri seperti:

[2024-10-XX XX:XX:XX] processing: App\Jobs\SyncInventory (attempt 1) [queue:sync, connection:redis]
[2024-10-XX XX:XX:52] failure: App\Jobs\SyncInventory (attempt 1) [TimeoutException: Job timed out before finishing]
[2024-10-XX XX:XX:53] redis: command failed: EBUSY lock (while acquiring lock)

Catatan terakhir biasanya folder Supervisor mesti di-reset agar job kembali berjalan. Selain itu, status job tidak pernah menyentuh processed dalam horizon dashboard.

Metode Observasi Redis

Untuk memastikan lock tertahan, jalankan redis-cli INFO clients dan redis-cli CLIENT LIST untuk melihat koneksi panjang. Gunakan redis-cli MONITOR atau redis-cli --stat selama job berjalan untuk memantau perintah SET pada key lock dan UNLINK yang tidak pernah datang. Juga, redis-cli INFO keyspace memperlihatkan key lock tetap ada selama durasi job lebih lama dari ekspektasi.

Cara Reproduksi dan Analisis Deadlock

Langkah Reproduksi

  1. Jalankan scheduler lokal: php artisan schedule:work.
  2. Pastikan job SyncInventory disetel untuk dijalankan setiap 5 menit dan mendorong ke queue Redis dengan dispatch(new SyncInventory())->onQueue('sync').
  3. Gunakan Horizon worker atau php artisan queue:work redis --sleep=3 --tries=1 untuk memproses job.
  4. Perhatikan job kedua yang mulai sebelum job pertama menyelesaikan proses chunking data dan mencoba mengunci resource yang sama.

Reproduksi dapat lebih mudah dengan menambahkan delay artifisial di awal job menggunakan sleep(15) agar job lama tetap memegang lock ketika job berikutnya dipicu dari scheduler.

Analisis Deadlock

Karena job dijadwalkan sering dan data chunking cukup besar, job pertama memegang lock Redis menggunakan Cache::lock agar tidak ada dua proses yang memodifikasi cache atau API eksternal sekaligus. Seringkali job berikutnya mulai sebelum job pertama selesai, mencoba mendapatkan lock yang sama, kemudian menunggu timeout lama. Bila job pertama gagal melepaskan lock (misalnya exception sebelum release), maka key lock tetap ada sehingga job berikutnya langsung gagal.

Root Cause: Lock Redis Tidak Optimal

Implementasi awal menggunakan pattern seperti:

public function handle()
{
    $lock = Cache::lock('sync_inventory', 120);

    if (! $lock->get()) {
        throw new \\Exception('Job sedang berjalan');
    }

    try {
        $this->processAll();
    } finally {
        $lock->release();
    }
}

Masalahnya: jika processAll() berjalan lebih lama dari 120 detik atau melempar exception tanpa menangkapnya, release() tidak pernah dipanggil dan lock tetap berada di Redis. Scheduler akan mencoba job baru yang langsung gagal karena lock belum expired, menyamakan keadaan deadlock.

Selain itu, kuatnya ketergantungan terhadap release() menyebabkan job menjadi fragile saat dipaksa restart oleh Horizon atau supervisor.

Langkah Perbaikan

Optimasi Lock dengan block()

Pakai block() untuk menunggu lock dengan batas waktu pendek dan otomatis melepaskan saat closure selesai. Contoh yang lebih tangguh:

Cache::lock('sync_inventory', 90)->block(10, function () {
    $this->processAll();
});

Dengan cara ini, job yang masuk terakhir akan menunggu sampai 10 detik untuk lock dibebaskan, bukan langsung gagal. Jika lock tidak tersedia dalam 10 detik, job akan melempar LockTimeoutException yang bisa kita tangani untuk mencatat retry nanti.

Perpanjangan Timeout Dinamis

Jika job bisa lebih lama dari 90 detik, pertimbangkan memecah proses menjadi chunk agar setiap iterasi bisa memperpanjang lock dengan $lock->extend(60). Namun, jangan menambah durasi lock sembarangan karena dapat memperpanjang periode deadlock apabila job berhenti mendadak.

Chunking dan Batch yang Lebih Kecil

Daripada memproses ribuan model sekaligus, bagi dalam chunk kecil dengan loop yang mudah dieksekusi ulang. Contoh untuk koleksi besar:

Model::chunk(200, function ($items) use ($lock) {
    $this->processChunk($items);
    Cache::lock('sync_inventory', 90)->extend(60);
});

Chunking mengurangi waktu di dalam lock dan memperkecil risiko job lain terjebak menunggu terlalu lama. Jika chunk gagal, Anda dapat mendeteksi dan menjadwalkan ulang chunk tertentu tanpa memengaruhi job secara keseluruhan.

Moment Logging dan Timeout Explicit

Tambahkan log spesifik saat mengambil dan melepaskan lock agar bisa menelusuri nilai-nilai timestamp. Gunakan Log::debug('Lock acquired', ['time' => now()]) dan log ketika exception ditangkap. Selain itu, atur timeout job dan retry di public $timeout = 180; dan public $tries = 3; agar Horizon dapat membatalkan job yang tidak responsif.

Verifikasi Pasca Perbaikan

Monitoring

Setelah perbaikan, monitor Horizon dashboard untuk memastikan tidak ada job stuck di status processing lebih lama dari timeout. Pantau juga Redis keyspace dengan redis-cli INFO keyspace untuk melihat apakah key lock tidak tersisa dalam jangka panjang.

Unit Test atau Simulasi Lokal

Jalankan job dengan skenario paralel di lingkungan staging: jalankan dua proses php artisan queue:work bersamaan dan pastikan job kedua menunggu lock atau gagal dengan exception yang dapat ditangani. Catat hasil log untuk memastikan timeout dan release berfungsi.

Periksa Metrik

Gunakan redis-cli DBSIZE atau monitoring APM untuk memastikan perintah SET/UNLINK terjadi seperti yang diharapkan. Pastikan Horizon metrik latency job mengecil dan tidak ada antrian backlog yang menumpuk.

Pertimbangan Tambahan

Lock Redis tidak sempurna untuk skenario yang membutuhkan tinggi ketersediaan; jika deadlock tetap terjadi, pertimbangkan menggunakan database row-level locking dengan SELECT ... FOR UPDATE atau atomic queue seperti Laravel distributed lock Cache::lock berbasis Redlock. Namun, solusi tersebut membutuhkan koordinasi distribusi waktu dan pemulihan error yang lebih kompleks.