Laravel Queue Job Bekerja Dua Kali? Penyebab dan Cara Mencegahnya

Kenapa Laravel Queue Job Bisa Bekerja Dua Kali?

Laravel Queue dirancang untuk memastikan Job tetap diproses ketika terjadi kegagalan tertentu.

Namun, konsekuensinya adalah sebuah Job dapat diproses lebih dari satu kali dalam kondisi tertentu.

Misalnya:

Queue
  ↓
Job
  ↓
Worker memproses
  ↓
Proses sebenarnya berhasil
  ↓
Worker mengalami masalah sebelum Job dianggap selesai
  ↓
Job diproses kembali

Akibatnya, developer dapat melihat gejala seperti:

Kirim email
Kirim email lagi

atau

Update order
Update order lagi

atau

API dipanggil
API dipanggil lagi

Hal ini bukan selalu berarti Laravel mengalami bug.

Sering kali penyebabnya adalah kombinasi antara retry, timeout, worker termination, deployment, concurrency, atau desain Job yang tidak idempotent.

Contoh Laravel Queue Job Diproses Dua Kali

Misalnya kita memiliki Job:

class SendInvoiceEmail implements ShouldQueue
{
    public function handle(): void
    {
        Mail::to($this->email)
            ->send(new InvoiceMail($this->invoice));
    }
}

Kemudian:

SendInvoiceEmail::dispatch($invoice);

Secara normal:

Dispatch
   ↓
Queue
   ↓
Worker
   ↓
handle()
   ↓
Email terkirim

Tetapi dalam kondisi tertentu:

Dispatch
   ↓
Queue
   ↓
Worker A
   ↓
handle()
   ↓
Email terkirim
   ↓
Worker mengalami timeout/crash
   ↓
Job dianggap belum selesai
   ↓
Worker mengambil Job lagi
   ↓
Email terkirim lagi

Hasil akhirnya:

User menerima 2 email

Inilah salah satu alasan mengapa Job tidak boleh diasumsikan hanya akan berjalan sekali.

Apakah Laravel Queue Menjamin Job Hanya Berjalan Sekali?

Tidak.

Laravel Queue sebaiknya dipahami menggunakan model at-least-once processing.

Artinya, sistem Queue berusaha memastikan Job diproses, tetapi dalam kondisi tertentu Job dapat diproses kembali.

Ini berbeda dengan jaminan:

Exactly once

yang berarti sebuah pekerjaan benar-benar hanya dieksekusi satu kali.

Dalam sistem distributed processing, exactly-once execution sangat sulit dijamin.

Karena itu, pendekatan yang lebih aman adalah:

Anggap Job dapat dijalankan ulang dan buat Job tersebut aman ketika terjadi retry atau duplicate processing.

Konsep ini disebut idempotency.

Apa Itu Idempotency?

Idempotency berarti menjalankan operasi yang sama berkali-kali menghasilkan efek akhir yang sama seperti menjalankannya sekali.

Contoh sederhana:

$order->update([
    'status' => 'paid',
]);

Jika operasi tersebut dijalankan dua kali:

paid
 ↓
paid

hasil akhirnya tetap:

paid

Tetapi operasi seperti:

$user->increment('balance', 100);

berbeda.

Jika dijalankan dua kali:

100
 ↓
200
 ↓
300

Operasi tersebut menghasilkan efek ganda.

Karena itu, Job dengan operasi increment, payment, pengiriman email, atau pemanggilan API harus dirancang lebih hati-hati.

Penyebab Laravel Queue Job Berjalan Dua Kali

Ada beberapa penyebab yang paling umum:

  1. timeout Job terlalu pendek
  2. retry_after terlalu kecil
  3. Worker crash
  4. Server restart
  5. Worker dimatikan secara paksa
  6. Deployment dilakukan tanpa graceful restart
  7. Multiple Worker memproses Job yang sama
  8. Job gagal setelah efek samping sudah terjadi
  9. Job memang di-dispatch lebih dari sekali
  10. Tidak menggunakan mekanisme idempotency
  11. Concurrency tidak dikontrol
  12. API eksternal timeout setelah request sebenarnya berhasil

Mari kita bahas satu per satu.

Penyebab 1: Queue Job Mengalami Timeout

Ini salah satu penyebab yang sering terjadi.

Misalnya:

public $timeout = 30;

Job membutuhkan 60 detik.

Maka:

Job mulai
 ↓
30 detik
 ↓
Timeout
 ↓
Worker menghentikan Job

Masalahnya, bagaimana jika pekerjaan sebenarnya sudah melakukan sebagian efek sebelum timeout?

Misalnya:

Job
 ↓
Update database
 ↓
Kirim API
 ↓
Proses terlalu lama
 ↓
Timeout

Job kemudian dapat dianggap gagal dan dicoba kembali.

Hasilnya:

Update database
 ↓
Kirim API
 ↓
Retry
 ↓
Update database lagi
 ↓
Kirim API lagi

Karena itu, timeout harus dikonfigurasi dengan benar.

Penyebab 2: retry_after Terlalu Kecil

Jika menggunakan Queue backend seperti Redis atau database, ada mekanisme untuk menentukan berapa lama Job dianggap sedang diproses sebelum dapat tersedia kembali.

Laravel memiliki konfigurasi:

'retry_after' => 90,

Nilainya dapat berbeda tergantung versi dan konfigurasi Queue.

Misalnya:

retry_after = 60

Tetapi Job membutuhkan:

90 detik

Maka Worker A masih mengerjakan Job ketika Job tersebut sudah dianggap dapat diambil kembali.

Akibatnya:

Worker A
   ↓
Job A
   ↓
Masih bekerja...

Setelah 60 detik

Worker B
   ↓
Mengambil Job A

Sekarang terdapat:

Worker A → Job A
Worker B → Job A

Job yang sama dapat berjalan secara bersamaan.

Hubungan timeout dan retry_after

Ini bagian yang sangat penting.

Secara umum, retry_after harus lebih besar daripada timeout Worker agar Job tidak dianggap tersedia kembali ketika Worker sebenarnya masih menjalankannya.

Contohnya:

Job timeout = 60 detik
retry_after = 90 detik

Lebih aman daripada:

Job timeout = 90 detik
retry_after = 60 detik

Karena konfigurasi kedua berpotensi membuat Job terlihat tersedia kembali sebelum Worker selesai.

Namun, nilai sebenarnya harus disesuaikan dengan workload, queue backend, Worker, dan kebutuhan aplikasi.

Penyebab 3: Worker Crash

Worker dapat mengalami crash karena:

  • Out of memory
  • Fatal error
  • PHP process crash
  • Server restart
  • Redis connection problem
  • Dependency error
  • Process manager menghentikan Worker

Misalnya:

Worker
 ↓
Job dimulai
 ↓
Proses
 ↓
Worker crash

Queue backend dapat menganggap Job belum selesai.

Setelah visibility/retry period terpenuhi:

Queue
 ↓
Job tersedia lagi
 ↓
Worker baru
 ↓
Job diproses kembali

Jika Job sudah melakukan sebagian pekerjaan sebelumnya, duplicate effect dapat terjadi.

Penyebab 4: Server Restart

Misalnya server VPS direstart:

Worker
 ↓
Job sedang diproses
 ↓
Server restart
 ↓
Worker mati

Setelah server kembali:

Supervisor
 ↓
Worker baru
 ↓
Job dapat diproses kembali

Hal ini merupakan salah satu alasan mengapa Job harus aman ketika dijalankan ulang.

Penyebab 5: Worker Dimatikan Secara Paksa

Misalnya:

kill -9 <PID>

atau process manager menghentikan PHP process secara paksa.

Jika Job belum benar-benar selesai, Job dapat kembali tersedia untuk diproses.

Untuk deployment dan maintenance, sebaiknya gunakan mekanisme graceful shutdown.

Laravel menyediakan:

php artisan queue:restart

Command tersebut memberi sinyal kepada Worker agar berhenti setelah menyelesaikan Job yang sedang dikerjakan.

Penyebab 6: Deployment Tanpa Restart Worker

Ini kasus yang sering terjadi pada Laravel production.

Misalnya:

Deploy
 ↓
Git pull
 ↓
Code berubah
 ↓
Worker lama masih berjalan

Worker adalah long-running process.

Kode yang sudah dimuat oleh Worker tidak otomatis berubah hanya karena file aplikasi berubah.

Setelah deployment, gunakan:

php artisan queue:restart

Kemudian process manager seperti Supervisor dapat menjalankan Worker kembali.

Alurnya:

Deployment
 ↓
queue:restart
 ↓
Worker menyelesaikan Job
 ↓
Worker berhenti
 ↓
Supervisor menjalankan Worker baru

Ini lebih aman daripada membunuh process Worker secara paksa.

Penyebab 7: Job Memang Di-dispatch Dua Kali

Tidak semua duplicate Job disebabkan Queue Worker.

Bisa jadi aplikasinya sendiri melakukan:

ProcessOrder::dispatch($order);
ProcessOrder::dispatch($order);

Akibatnya memang ada dua Job.

Queue bekerja sesuai perintah yang diberikan:

Dispatch #1 → Job A
Dispatch #2 → Job B

Kemudian Worker memproses:

Job A
 ↓
Job B

Jika keduanya melakukan pekerjaan yang sama, hasilnya terlihat seperti duplicate processing.

Cek Controller yang Melakukan Dispatch

Misalnya:

public function store(Request $request)
{
    $order = Order::create([
        // ...
    ]);

    ProcessOrder::dispatch($order);

    return redirect()->back();
}

Pastikan method tersebut tidak terpanggil dua kali.

Hal ini dapat terjadi karena:

  • Double submit
  • User refresh halaman
  • JavaScript mengirim request dua kali
  • AJAX request duplicate
  • Webhook dikirim ulang
  • API client melakukan retry
  • Event listener terdaftar lebih dari sekali

Penyebab 8: Event Listener Menjalankan Job Dua Kali

Misalnya:

OrderCreated::dispatch($order);

Kemudian listener:

class ProcessOrderListener
{
    public function handle(OrderCreated $event)
    {
        ProcessOrder::dispatch($event->order);
    }
}

Jika listener terdaftar dua kali, Job juga dapat di-dispatch dua kali.

Karena itu, ketika menemukan duplicate Job, jangan hanya memeriksa Worker.

Periksa juga:

Controller
 ↓
Event
 ↓
Listener
 ↓
Job
 ↓
Queue

Cari di mana duplicate terjadi.

Penyebab 9: Webhook Dipanggil Dua Kali

Webhook dari service eksternal juga sering menjadi sumber duplicate Job.

Contoh:

Payment Gateway
      ↓
Webhook
      ↓
Laravel
      ↓
ProcessPayment

Jika server Laravel lambat merespons:

Webhook
 ↓
Timeout

Payment gateway mungkin mengirim webhook kembali:

Webhook #1
 ↓
Laravel

Webhook #2
 ↓
Laravel

Akhirnya:

ProcessPayment
ProcessPayment

Ini bukan masalah Queue saja.

Endpoint webhook juga harus dibuat idempotent.

Gunakan Unique ID untuk Setiap Operasi

Salah satu cara paling efektif untuk mencegah duplicate processing adalah memberikan identifier unik.

Contoh:

order_id
transaction_id
invoice_id
external_event_id

Misalnya:

transaction_id = PAY-2026-00123

Kemudian sebelum melakukan pekerjaan:

Apakah transaction_id ini sudah diproses?
        │
       Ya
        ↓
     Stop

       Tidak
        ↓
     Proses
        ↓
   Tandai selesai

Dengan demikian retry tidak menyebabkan proses bisnis dijalankan dua kali.

Gunakan Database Unique Constraint

Jangan hanya mengandalkan pengecekan di kode:

if (!Payment::where('transaction_id', $id)->exists()) {
    // insert
}

Dalam kondisi concurrent:

Worker A → Cek → belum ada
Worker B → Cek → belum ada

Keduanya dapat melakukan insert.

Lebih aman jika database juga memiliki unique constraint.

Contoh migration:

Schema::table('payments', function (Blueprint $table) {
    $table->unique('transaction_id');
});

Sekarang database sendiri mencegah duplicate:

PAY-001
PAY-001

Baris kedua akan ditolak.

Ini jauh lebih kuat untuk menjaga integritas data.

Idempotency dengan Status Database

Contoh Job:

public function handle(): void
{
    $order = Order::findOrFail($this->orderId);

    if ($order->status === 'processed') {
        return;
    }

    $order->update([
        'status' => 'processed',
    ]);
}

Jika Job dijalankan dua kali:

Attempt 1
 ↓
status = pending
 ↓
Update → processed

Attempt 2
 ↓
status = processed
 ↓
return

Job kedua tidak melakukan pekerjaan lagi.

Ini merupakan pola idempotency sederhana.

Namun untuk proses yang kompleks, pengecekan status saja belum tentu cukup. Gunakan transaction, unique constraint, lock, atau mekanisme idempotency key sesuai kebutuhan.

Idempotency untuk increment()

Operasi seperti:

$user->increment('points', 100);

berbahaya jika Job dapat dijalankan ulang.

Attempt pertama:

points = 0
 ↓
+100
 ↓
100

Attempt kedua:

100
 ↓
+100
 ↓
200

Padahal seharusnya:

100

Salah satu pendekatan adalah menyimpan record transaksi atau event yang memiliki ID unik.

Contohnya:

reward_events

event_id = ORDER-1001-REWARD

Kemudian database memastikan event_id hanya dapat diproses sekali.

Gunakan ShouldBeUnique

Laravel juga menyediakan mekanisme Unique Job.

Contohnya:

use Illuminate\Contracts\Queue\ShouldBeUnique;

class SyncProduct implements ShouldQueue, ShouldBeUnique
{
    public function __construct(
        public int $productId
    ) {}

    public function handle(): void
    {
        // Sync product
    }
}

Dengan ShouldBeUnique, Laravel dapat mencegah Job yang sama dimasukkan ke Queue ketika Job dengan unique identity yang sama masih dianggap aktif.

Untuk menentukan identifier unik, kita dapat menggunakan uniqueId():

public function uniqueId(): string
{
    return (string) $this->productId;
}

Sekarang:

SyncProduct(10)
SyncProduct(10)

dapat dianggap sebagai Job yang sama berdasarkan identifier tersebut.

Sedangkan:

SyncProduct(10)
SyncProduct(20)

adalah Job berbeda.

Kapan Menggunakan ShouldBeUnique?

Unique Job cocok ketika kita tidak ingin ada beberapa Job identik yang aktif secara bersamaan.

Contoh:

SyncProduct(100)

Tidak perlu ada:

Worker A → SyncProduct(100)
Worker B → SyncProduct(100)

Jika sinkronisasi produk membutuhkan waktu lama, unique Job dapat membantu mencegah dispatch duplicate.

Namun perlu diingat:

Unique Job bukan pengganti idempotency.

Job tetap harus aman jika diproses kembali akibat failure.

ShouldBeUnique Tidak Sama dengan Idempotency

Ini perbedaan penting.

ShouldBeUnique berfokus pada:

Mencegah duplicate Job tertentu masuk atau berjalan bersamaan.

Idempotency berfokus pada:

Jika Job tetap dijalankan dua kali,
efek akhirnya tetap aman.

Sistem production yang kritis sering membutuhkan keduanya.

Contohnya:

Unique Job
     +
Idempotent Processing
     +
Database Constraint

lebih kuat daripada hanya mengandalkan satu mekanisme.

Menggunakan WithoutOverlapping

Laravel juga menyediakan middleware WithoutOverlapping.

Contohnya:

use Illuminate\Queue\Middleware\WithoutOverlapping;

public function middleware(): array
{
    return [
        (new WithoutOverlapping($this->productId)),
    ];
}

Konsepnya:

Product #10

Worker A
 ↓
Lock Product #10
 ↓
Process

Worker B
 ↓
Mencoba Product #10
 ↓
Tidak dapat lock

Dengan demikian dua Job yang memproses resource yang sama tidak berjalan bersamaan.

ShouldBeUnique vs WithoutOverlapping

Keduanya sering membingungkan.

ShouldBeUnique

Lebih fokus pada:

Apakah Job duplicate perlu masuk/aktif?

WithoutOverlapping

Lebih fokus pada:

Apakah Job dengan resource yang sama boleh berjalan bersamaan?

Contoh:

SyncProduct(10)
SyncProduct(10)

Jika keduanya masuk Queue, WithoutOverlapping dapat mencegah keduanya berjalan bersamaan berdasarkan lock yang digunakan.

Lock Redis untuk Mencegah Concurrent Processing

Jika aplikasi menggunakan Redis, lock juga dapat digunakan.

Contoh konsep:

$lock = Cache::lock('order:' . $orderId, 60);

if ($lock->get()) {
    try {
        // Proses order
    } finally {
        $lock->release();
    }
}

Konsepnya:

Worker A
 ↓
Lock order:100
 ↓
Process

Worker B
 ↓
Lock order:100
 ↓
Tidak mendapatkan lock

TTL lock harus dipilih dengan hati-hati.

Lock yang terlalu pendek dapat habis sebelum pekerjaan selesai.

Lock yang terlalu panjang dapat menghambat proses ketika Worker benar-benar crash.

Gunakan Database Transaction

Untuk operasi database yang harus konsisten, transaction dapat membantu.

Contoh:

DB::transaction(function () use ($order) {
    $order->update([
        'status' => 'processed',
    ]);

    OrderLog::create([
        'order_id' => $order->id,
        'action' => 'processed',
    ]);
});

Jika salah satu operasi gagal, transaction dapat di-rollback.

Namun transaction tidak membuat Job otomatis idempotent.

Misalnya:

Database transaction
 ↓
Update database
 ↓
Call API eksternal

Transaction database tidak dapat membatalkan request yang sudah dikirim ke API eksternal.

Karena itu, efek samping eksternal harus ditangani dengan strategi tersendiri.

Hati-Hati dengan API Eksternal

Salah satu kasus paling sulit adalah:

Laravel
 ↓
POST Payment API
 ↓
Payment berhasil
 ↓
Network timeout

Laravel melihat:

Timeout

Kemudian Queue melakukan retry.

Retry
 ↓
POST Payment API
 ↓
Payment berhasil lagi?

Tanpa idempotency key, payment gateway dapat memproses transaksi kedua.

Solusinya adalah menggunakan idempotency key jika API yang digunakan mendukungnya.

Contoh konsep:

Idempotency-Key:
PAYMENT-ORDER-10001

Request pertama:

PAYMENT-ORDER-10001
→ Process

Request kedua:

PAYMENT-ORDER-10001
→ Return existing result

Dengan begitu retry menjadi lebih aman.

Job Berjalan Dua Kali Karena retry_after

Mari lihat contoh yang sering terjadi.

Konfigurasi:

timeout = 120
retry_after = 90

Job membutuhkan:

100 detik

Alurnya:

00s  Worker A mengambil Job
30s  Job masih berjalan
60s  Job masih berjalan
90s  Job kembali terlihat tersedia
100s Worker A selesai

Jika Worker B mengambil Job pada detik ke-90:

Worker A → Job
Worker B → Job

Sekarang Job yang sama berjalan dua kali.

Karena itu, perhatikan hubungan timeout dengan retry_after.

Jangan Membuat retry_after Terlalu Pendek

Jika Job normalnya membutuhkan 60 detik, jangan membuat:

retry_after = 30

karena Worker lain dapat mengambil Job tersebut sebelum Worker pertama selesai.

Lebih aman memiliki margin:

Job timeout
<
retry_after

Contoh:

timeout = 60
retry_after = 90

atau sesuaikan dengan durasi maksimal Job dan karakteristik workload.

Jangan Membuat Timeout Terlalu Tinggi

Sebaliknya, jangan langsung menggunakan:

timeout = 3600

hanya agar duplicate processing tidak terjadi.

Jika Job sebenarnya hang selama satu jam:

Worker
 ↓
Job stuck
 ↓
1 jam
 ↓
baru dianggap timeout

Worker menjadi tidak efektif.

Lebih baik cari penyebab Job lambat dan tentukan timeout berdasarkan kebutuhan nyata.

Cek Konfigurasi Worker

Misalnya:

php artisan queue:work redis \
    --sleep=3 \
    --tries=3 \
    --timeout=120

Perhatikan:

--tries
--timeout
--sleep

Worker juga dapat memiliki konfigurasi memory dan lifecycle.

Untuk production, parameter tersebut harus disesuaikan dengan workload.

Worker Jangan Terlalu Banyak

Menambah Worker memang meningkatkan throughput.

Misalnya:

1 Worker
 ↓
10 Job/detik

Kemudian:

10 Worker
 ↓
100 Job/detik

Namun bukan berarti semakin banyak Worker selalu lebih baik.

Terlalu banyak Worker dapat menyebabkan:

  • Database overload
  • Redis overload
  • API rate limit
  • CPU tinggi
  • Memory habis
  • Lock contention

Dan pada proses tertentu, concurrency berlebihan justru meningkatkan risiko konflik.

Cek Supervisor

Jika menggunakan Supervisor:

sudo supervisorctl status

Pastikan Worker yang aktif memang sesuai konfigurasi.

Contohnya:

laravel-worker:laravel-worker_00   RUNNING
laravel-worker:laravel-worker_01   RUNNING
laravel-worker:laravel-worker_02   RUNNING

Jika ternyata ada dua konfigurasi Supervisor berbeda yang menjalankan queue yang sama tanpa sengaja, jumlah Worker bisa jauh lebih banyak daripada yang diperkirakan.

Periksa Apakah Job Memang Duplicate

Jika menemukan:

Order #100 diproses dua kali

jangan langsung menyimpulkan Queue bermasalah.

Cari tahu:

Apakah Job di-dispatch dua kali?
       ↓
Ya → Perbaiki dispatch

Tidak
       ↓
Apakah Job retry?
       ↓
Ya → Periksa timeout/error

Tidak
       ↓
Apakah Worker concurrent?
       ↓
Ya → Periksa retry_after/lock

Tidak
       ↓
Periksa webhook/event

Debugging menjadi jauh lebih mudah jika kita mengetahui di mana duplicate terjadi.

Tambahkan Logging pada Job

Saat debugging, tambahkan log:

Log::info('ProcessOrder started', [
    'order_id' => $this->orderId,
]);

Dan:

Log::info('ProcessOrder finished', [
    'order_id' => $this->orderId,
]);

Kita juga dapat mencatat identifier Job atau informasi attempt yang tersedia pada konteks Job.

Tujuannya adalah mengetahui:

Job A
started
finished

atau

Job A
started
started
finished
finished

Dari sini kita dapat membedakan duplicate dispatch dengan retry/concurrent processing.

Cek attempts()

Dalam Job, informasi attempt dapat digunakan untuk debugging.

Contohnya:

public function handle(): void
{
    Log::info('Processing order', [
        'order_id' => $this->orderId,
        'attempt' => $this->attempts(),
    ]);

    // Proses
}

Jika log menunjukkan:

attempt = 1
attempt = 2

kemungkinan Job yang sama sedang mengalami retry.

Jika terdapat dua Job berbeda dengan attempt masing-masing:

Job A → attempt 1
Job B → attempt 1

kemungkinan aplikasi melakukan duplicate dispatch.

Cara Mencegah Job Diproses Dua Kali

Tidak ada satu solusi untuk semua kasus.

Gunakan kombinasi sesuai kebutuhan.

1. Buat Job Idempotent

Pastikan menjalankan Job dua kali tidak menyebabkan efek samping ganda.

2. Gunakan Unique Constraint

Contohnya:

$table->unique('transaction_id');

Database menjadi lapisan perlindungan terakhir.

3. Gunakan ShouldBeUnique

Untuk mencegah duplicate Job berdasarkan identifier tertentu.

4. Gunakan WithoutOverlapping

Untuk mencegah dua Job dengan resource sama berjalan bersamaan.

5. Gunakan Lock

Redis atau cache lock dapat digunakan untuk operasi yang membutuhkan mutual exclusion.

6. Atur timeout dan retry_after

Pastikan Job tidak tersedia kembali ketika Worker sebenarnya masih memprosesnya.

7. Gunakan Graceful Worker Restart

Saat deployment:

php artisan queue:restart

8. Gunakan Idempotency Key untuk API

Terutama untuk:

  • Payment
  • Order
  • External API

Contoh Job yang Lebih Aman

Misalnya kita memiliki Job untuk memproses order.

Daripada:

public function handle(): void
{
    $this->order->increment('processed_count');
}

kita dapat menggunakan status atau record proses.

Contoh sederhana:

public function handle(): void
{
    $order = Order::findOrFail($this->orderId);

    if ($order->processed_at) {
        return;
    }

    $order->update([
        'processed_at' => now(),
    ]);
}

Jika Job berjalan dua kali:

Attempt 1
 ↓
processed_at kosong
 ↓
Set processed_at

Attempt 2
 ↓
processed_at sudah ada
 ↓
return

Untuk proses yang lebih kompleks, gunakan transaction dan locking yang sesuai.

Contoh dengan Database Lock

Untuk kondisi concurrent, kita dapat menggunakan row lock:

DB::transaction(function () {
    $order = Order::query()
        ->lockForUpdate()
        ->findOrFail($this->orderId);

    if ($order->processed_at) {
        return;
    }

    $order->update([
        'processed_at' => now(),
    ]);
});

Jika dua Worker mencoba memproses order yang sama secara bersamaan:

Worker A
 ↓
lockForUpdate()
 ↓
Process

Worker B
 ↓
Menunggu lock
 ↓
Membaca kondisi terbaru
 ↓
processed_at sudah terisi
 ↓
Tidak memproses lagi

Ini dapat membantu pada kasus concurrent database processing.

Jangan Hanya Mengandalkan ShouldBeUnique

Misalnya:

class ProcessPayment implements ShouldQueue, ShouldBeUnique

Ini bagus.

Tetapi tetap jangan menganggap:

ShouldBeUnique
=
Exactly Once

Unique Job membantu mencegah duplicate Job dalam kondisi tertentu.

Tetapi Job tetap harus aman terhadap:

  • Worker crash
  • Timeout
  • Retry
  • External API timeout
  • Server restart

Karena itu:

Unique Job
+
Idempotency

lebih kuat.

Kapan Duplicate Job Justru Normal?

Dalam sistem Queue, duplicate execution dapat terjadi karena sifat at-least-once processing.

Jadi:

Duplicate execution
≠ selalu bug Laravel

Yang menjadi masalah adalah ketika aplikasi tidak siap menghadapinya.

Contohnya:

Job dijalankan dua kali
 ↓
Email terkirim dua kali

Jika email ganda tidak diinginkan, aplikasi perlu memiliki strategi deduplikasi atau idempotency yang sesuai.

Bagaimana Menentukan Solusi yang Tepat?

Gunakan panduan sederhana berikut.

MasalahSolusi yang Dipertimbangkan
Job di-dispatch dua kaliPerbaiki dispatch
Job sama berjalan bersamaanWithoutOverlapping / lock
Duplicate dispatchShouldBeUnique
Retry menyebabkan efek gandaIdempotency
Payment diproses dua kaliIdempotency key
Database duplicateUnique constraint
Worker timeoutPerbaiki timeout
Job muncul kembali terlalu cepatPeriksa retry_after
Deployment mengganggu Workerqueue:restart
Webhook duplicateIdempotent webhook
Job gagal setelah efek sampingIdempotent processing

Checklist Debugging Laravel Queue Job yang Berjalan Dua Kali

Jika mengalami masalah ini di production, cek secara berurutan:

Cek Apakah Job Di-dispatch Dua Kali

Cari:

SomeJob::dispatch(...)

Periksa controller, event, listener, command, scheduler, dan webhook.

Cek Attempt

Tambahkan logging:

$this->attempts()

Jika attempt bertambah:

1 → 2 → 3

berarti ada retry.

Cek Timeout

Periksa:

public $timeout = 120;

dan konfigurasi Worker.

Cek retry_after

Pastikan tidak lebih kecil daripada durasi Job yang dibutuhkan.

Cek Worker

php artisan queue:work

dan:

sudo supervisorctl status

Cek Log

Periksa:

storage/logs/laravel.log

dan log Supervisor.

Cek Redis

Jika menggunakan Redis:

redis-cli ping

Pastikan Redis normal.

Cek Idempotency

Tanyakan:

Jika Job ini dijalankan dua kali, apakah data tetap benar?

Jika jawabannya tidak, Job perlu diperbaiki.

Kesimpulan

Laravel Queue Job yang bekerja dua kali tidak selalu berarti Laravel mengalami bug. Dalam sistem Queue, sebuah Job dapat diproses kembali ketika terjadi timeout, Worker crash, server restart, retry, deployment, atau kondisi concurrency tertentu.

Penyebab yang paling penting untuk diperiksa adalah:

timeout
retry_after
Worker crash
duplicate dispatch
retry
concurrency
webhook duplicate

Untuk mencegah efek buruk dari duplicate processing, gunakan kombinasi:

Idempotency
     +
Unique Constraint
     +
ShouldBeUnique
     +
WithoutOverlapping
     +
Lock
     +
timeout & retry_after yang benar

Untuk Job sederhana, pengecekan status mungkin sudah cukup:

if ($order->processed_at) {
    return;
}

Untuk proses yang lebih kritis seperti payment, jangan hanya mengandalkan pengecekan sederhana. Gunakan idempotency key, unique constraint, transaction, dan mekanisme locking sesuai kebutuhan.

Prinsip yang paling penting adalah:

Anggap setiap Laravel Queue Job dapat dijalankan lebih dari sekali, lalu desain Job agar aman ketika hal tersebut terjadi.

Dengan prinsip tersebut, aplikasi akan jauh lebih tahan terhadap retry, Worker crash, deployment, concurrency, dan gangguan service eksternal.