Saat job Laravel yang seharusnya unique tetap berjalan ganda, banyak tim langsung menyalahkan Horizon, Supervisor, atau jumlah worker yang terlalu banyak. Padahal, akar masalah paling sering ada pada mekanisme lock di cache. Fitur seperti ShouldBeUnique dan middleware WithoutOverlapping sama-sama bergantung pada lock yang dibuat oleh cache driver. Jika driver tidak tepat, key lock tidak konsisten, TTL terlalu pendek, atau uniqueId() tidak stabil, dua worker tetap bisa mengeksekusi job identik secara paralel.

Artikel ini membahas kasus nyata ketika ShouldBeUnique atau WithoutOverlapping tetap tembus, fokus pada empat area yang paling sering bermasalah: driver cache, TTL lock, Horizon/Supervisor dengan banyak worker, dan perbedaan uniqueId yang tidak stabil. Di akhir, ada contoh implementasi yang benar dan langkah verifikasi langsung di Redis.

Memahami Perbedaan: ShouldBeUnique vs WithoutOverlapping

Sebelum masuk ke debugging, penting memahami bahwa kedua mekanisme ini bekerja di tahap yang berbeda.

ShouldBeUnique

ShouldBeUnique mencegah job identik didispatch lebih dari sekali selama lock masih aktif. Artinya, kontrol terjadi saat proses dispatching, bukan saat job mulai diproses worker. Jika lock berhasil dibuat, dispatch berikutnya untuk job dengan identitas yang sama akan ditolak atau diabaikan.

Ini cocok untuk kasus seperti sinkronisasi data akun tertentu, impor file yang sama, atau pengiriman notifikasi yang tidak boleh diantre berkali-kali.

WithoutOverlapping

WithoutOverlapping adalah middleware job yang mencegah dua job dengan key sama berjalan bersamaan. Job tetap bisa masuk queue beberapa kali, tetapi saat worker mengeksekusi, hanya satu yang boleh memegang lock pada waktu tertentu.

Ini cocok jika job boleh menumpuk di antrean, tetapi tidak boleh memproses resource yang sama secara paralel. Misalnya, rekalkulasi saldo user, pembaruan inventory SKU, atau sinkronisasi API per tenant.

Konsekuensi Praktis

  • ShouldBeUnique: fokus pada deduplikasi saat enqueue.
  • WithoutOverlapping: fokus pada serialisasi saat runtime.
  • Keduanya sama-sama bisa gagal jika lock cache tidak benar-benar atomic atau key lock tidak konsisten.

Penyebab Paling Umum Job Tetap Berjalan Paralel

1. Driver Cache Tidak Mendukung Lock Secara Andal

Fitur unique job Laravel mengandalkan cache lock. Secara praktis, driver seperti Redis atau Memcached lebih aman dipakai untuk skenario multi-worker karena mendukung operasi atomic lock. Sebaliknya, memakai file, array, atau konfigurasi cache yang berbeda-beda antar proses bisa membuat lock tidak efektif, terutama jika Anda menjalankan banyak worker pada banyak container atau server.

Kasus yang sering terjadi:

  • QUEUE_CONNECTION memakai Redis, tetapi CACHE_DRIVER masih file.
  • Aplikasi berjalan pada beberapa pod/container, tetapi cache file tersimpan lokal di masing-masing instance.
  • Environment worker berbeda dengan environment aplikasi web, sehingga keduanya memakai store cache berbeda.

Jika lock harus berlaku lintas worker, lintas proses, atau lintas host, gunakan cache terpusat seperti Redis. Lock lokal per mesin tidak cukup untuk mencegah duplikasi secara global.

2. TTL Lock Terlalu Pendek

Lock bukan selamanya. Baik ShouldBeUnique maupun WithoutOverlapping bergantung pada masa hidup lock. Jika TTL lock habis sebelum job selesai, worker lain bisa mengambil lock yang sama dan mengeksekusi job identik secara paralel.

Contoh klasik:

  • Job rata-rata selesai dalam 90 detik.
  • TTL lock diset hanya 30 detik.
  • Setelah detik ke-30, worker lain bisa menganggap lock sudah hilang.

Hasilnya: dua proses aktif pada resource yang sama, walaupun kode tampak sudah memakai proteksi unique.

Masalah ini makin sering muncul jika durasi job tidak stabil, misalnya tergantung respons API eksternal, ukuran file, atau beban database.

3. Horizon atau Supervisor Menjalankan Banyak Worker Secara Bersamaan

Multi-worker bukan masalah; justru itulah tujuan queue. Masalah muncul ketika banyak worker aktif, tetapi mekanisme lock tidak benar-benar global atau tidak cukup lama. Dalam konfigurasi Horizon atau Supervisor, puluhan worker dapat mengambil job hampir bersamaan dalam selang milidetik. Jika lock gagal dibuat secara atomic, race condition akan terlihat jauh lebih sering.

Gejala khasnya:

  • Pada local development job tampak aman.
  • Di staging atau production, ketika worker diperbanyak, job identik mulai dobel.
  • Duplikasi lebih sering saat traffic tinggi atau saat ada batch dispatch besar.

Artinya, masalahnya bukan pada banyaknya worker, melainkan pada validitas lock ketika ada konkurensi tinggi.

4. uniqueId() Tidak Stabil

Ini sumber bug yang sangat sering luput. Laravel hanya bisa menganggap dua job identik jika key uniknya benar-benar sama. Jika uniqueId() dibuat dari data yang berubah-ubah, dua job yang secara bisnis identik akan dianggap berbeda.

Contoh buruk:

  • Memasukkan now(), timestamp, random string, atau UUID baru ke dalam uniqueId().
  • Menggunakan seluruh payload JSON yang urutan field-nya bisa berubah.
  • Menggunakan object serialization yang hasilnya tidak konsisten.

Akibatnya, secara teknis lock memang bekerja, tetapi setiap dispatch membuat key baru sehingga tidak pernah benar-benar saling menahan.

Implementasi ShouldBeUnique yang Benar

Gunakan identifier yang stabil, deterministik, dan mewakili unit kerja yang memang ingin dibuat unik. Misalnya, sinkronisasi invoice berdasarkan ID invoice.

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class SyncInvoiceJob implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $uniqueFor = 300; // 5 menit

    public function __construct(public int $invoiceId)
    {
    }

    public function uniqueId(): string
    {
        return 'invoice:' . $this->invoiceId;
    }

    public function handle(): void
    {
        // proses sinkronisasi invoice
    }
}

Poin penting pada contoh di atas:

  • uniqueId() hanya menggunakan invoiceId, yang stabil.
  • uniqueFor memberi TTL lock yang eksplisit.
  • Nilai TTL harus cukup panjang untuk menutup durasi antrean dan eksekusi yang realistis.

Jika job kadang memakan waktu 2-3 menit, memberi uniqueFor = 60 jelas terlalu pendek.

Kesalahan yang Sering Terjadi

public function uniqueId(): string
{
    return 'invoice:' . $this->invoiceId . ':' . now()->timestamp;
}

Contoh di atas salah karena setiap dispatch menghasilkan key berbeda. Secara tampilan tampak unik, tetapi bukan unik dalam arti deduplikasi job identik.

Implementasi WithoutOverlapping yang Benar

Jika tujuan Anda bukan mencegah dispatch ganda, melainkan mencegah proses paralel terhadap resource yang sama, gunakan WithoutOverlapping.

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;

class RecalculateUserBalanceJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $userId)
    {
    }

    public function middleware(): array
    {
        return [
            (new WithoutOverlapping('user-balance:' . $this->userId))
                ->expireAfter(600)
                ->releaseAfter(10),
        ];
    }

    public function handle(): void
    {
        // proses rekalkulasi saldo user
    }
}

Penjelasan parameter penting:

  • Key lock harus stabil, misalnya user-balance:123.
  • expireAfter(600) memberi batas TTL lock 10 menit. Ini penting jika worker mati mendadak agar lock tidak menggantung selamanya.
  • releaseAfter(10) membuat job yang gagal mendapat lock dilepas kembali ke queue setelah 10 detik, bukan dibuang.

Tanpa expireAfter(), Anda berisiko punya lock yang terlalu lama atau perilaku yang tidak sesuai ekspektasi saat proses crash. Tetapi jika nilainya terlalu pendek, masalah paralel bisa muncul lagi.

Checklist Konfigurasi yang Wajib Dicek

Pastikan Cache Driver Terpusat

CACHE_STORE=redis
QUEUE_CONNECTION=redis

Nama environment variable bisa berbeda tergantung struktur aplikasi, tetapi intinya sama: cache store untuk lock sebaiknya Redis, terutama jika worker berjalan di banyak proses atau mesin.

Pastikan Semua Worker Menggunakan Environment yang Sama

Sering terjadi worker Horizon atau Supervisor belum direstart setelah perubahan .env. Akibatnya:

  • Web app sudah pakai Redis.
  • Worker lama masih pakai file cache atau konfigurasi lama.

Setelah mengubah cache atau queue config, lakukan restart yang sesuai, misalnya:

php artisan config:clear
php artisan cache:clear
php artisan horizon:terminate

Jika memakai Supervisor, restart proses worker agar environment dimuat ulang.

Sesuaikan TTL dengan Durasi Nyata Job

Jangan menebak TTL dari perkiraan optimistis. Gunakan durasi terburuk yang masih masuk akal. Jika job normalnya 20 detik tetapi bisa mencapai 4 menit ketika API lambat, TTL 30 detik adalah resep duplikasi.

Sebagai aturan praktis:

  • Pilih TTL lebih besar dari durasi eksekusi terburuk.
  • Tambahkan buffer untuk retry, network delay, atau startup worker.
  • Evaluasi ulang jika pola beban berubah.

Langkah Verifikasi Lock Langsung di Redis

Jika Anda ingin memastikan masalahnya benar-benar pada cache lock, verifikasi langsung di Redis. Ini jauh lebih akurat daripada menebak dari log aplikasi saja.

1. Hubungkan ke Redis

redis-cli

2. Cari Key yang Berkaitan dengan Job atau Lock

SCAN 0 MATCH *invoice* COUNT 100
SCAN 0 MATCH *laravel* COUNT 100
SCAN 0 MATCH *overlap* COUNT 100

Nama key internal bisa berbeda tergantung versi dan implementasi, jadi gunakan pola pencarian yang cukup luas lalu persempit.

3. Cek TTL Key Lock

TTL nama_key_lock

Interpretasinya:

  • Nilai positif: key akan kedaluwarsa dalam sejumlah detik.
  • -1: key ada tetapi tidak punya expire.
  • -2: key tidak ditemukan.

Jika lock hilang terlalu cepat saat job masih berjalan, kemungkinan TTL terlalu pendek. Jika key tidak pernah muncul, mungkin worker tidak memakai Redis store yang Anda kira, atau key yang dibentuk berbeda dari ekspektasi.

4. Pantau Secara Real-Time

MONITOR

Perintah ini sangat berguna di lingkungan uji untuk melihat operasi Redis secara langsung saat Anda mendispatch dua job identik. Anda bisa mengamati apakah lock dibuat, diperbarui, atau hilang terlalu cepat. Jangan gunakan MONITOR sembarangan di production dengan trafik tinggi karena biayanya mahal.

5. Uji dengan Dispatch Paralel

Lakukan percobaan sederhana:

  1. Dispatch dua job dengan resource ID yang sama dalam waktu hampir bersamaan.
  2. Pastikan worker lebih dari satu agar race condition benar-benar teruji.
  3. Lihat apakah hanya satu lock yang terbentuk dan apakah job kedua tertahan atau dilepas ulang.

Jika dua job tetap jalan bersamaan, cek kembali tiga hal: key lock, cache driver, dan TTL.

Kesalahan Debugging yang Sering Menyesatkan

Menganggap Queue Driver dan Cache Driver Itu Sama

Memakai Redis sebagai queue tidak otomatis berarti unique lock juga aman. Lock mengikuti cache store, bukan semata queue connection.

Menguji Hanya dengan Satu Worker

Pada satu worker, hampir semua mekanisme tampak benar karena tidak ada kompetisi nyata. Bug biasanya baru muncul saat ada dua atau lebih worker aktif.

Key Unik Dibuat dari Payload Mentah

Payload JSON, array, atau object sering tampak mewakili job yang sama, tetapi bisa memiliki urutan atau representasi berbeda. Lebih aman gunakan identifier bisnis yang eksplisit: userId, orderId, tenantId, invoiceId, atau kombinasi yang memang deterministik.

Kapan Memilih ShouldBeUnique dan Kapan WithoutOverlapping?

  • Pilih ShouldBeUnique jika Anda ingin mencegah job identik masuk queue berkali-kali.
  • Pilih WithoutOverlapping jika job boleh menumpuk, tetapi eksekusinya harus satu per satu untuk resource tertentu.
  • Gabungkan keduanya hanya jika Anda benar-benar butuh deduplikasi saat dispatch sekaligus proteksi saat runtime.

Namun, menggabungkan keduanya tanpa memahami perilakunya bisa menyulitkan debugging. Mulailah dari kebutuhan bisnis yang jelas: apakah masalah Anda ada di antrean yang berulang, atau di eksekusi paralel terhadap resource yang sama?

Penutup

Jika ShouldBeUnique atau WithoutOverlapping di Laravel terasa “tembus”, penyebabnya biasanya bukan karena fiturnya tidak bekerja, melainkan karena fondasi lock-nya bermasalah. Empat tersangka utama adalah cache driver yang tidak tepat, TTL lock terlalu pendek, konkurensi tinggi dari Horizon/Supervisor multi-worker, dan uniqueId yang tidak stabil.

Langkah paling aman untuk production adalah:

  • Gunakan Redis sebagai cache store untuk lock.
  • Buat key unik yang stabil dan deterministik.
  • Tentukan TTL berdasarkan durasi job yang realistis, bukan asumsi terbaik.
  • Uji pada multi-worker, bukan hanya satu worker.
  • Verifikasi langsung key dan TTL di Redis.

Dengan pendekatan ini, Anda tidak hanya “berharap” unique job bekerja, tetapi bisa membuktikan bahwa lock benar-benar ada, berlaku global, dan bertahan cukup lama untuk mencegah proses ganda.