Laravel Queue tidak menjamin sebuah job hanya dieksekusi sekali dalam semua kondisi. Dalam sistem nyata, job bisa berjalan ganda saat worker crash setelah efek samping terjadi, saat timeout terlalu pendek, saat retry bertumpuk, atau saat dua worker mengambil pekerjaan yang secara logis mewakili entitas yang sama.

Karena itu, pencegahan job ganda tidak cukup hanya mengandalkan satu fitur. Pendekatan yang lebih aman adalah menggabungkan locking untuk membatasi konkurensi, unique job untuk menahan dispatch duplikat, dan idempotensi untuk memastikan efek akhir tetap benar walaupun job terproses lebih dari sekali.

Masalah yang Sering Terjadi pada Job Queue

1. Retry bertumpuk

Jika job gagal lalu di-retry, sementara ada proses lain yang juga memicu job dengan payload serupa, Anda bisa mendapat dua atau lebih eksekusi yang menulis ke data yang sama. Ini umum pada event yang datang berulang, webhook, atau API yang di-call ulang oleh client.

2. Visibility timeout atau retry_after terlalu pendek

Pada backend queue seperti Redis atau sistem lain yang memakai mekanisme lease/visibility, sebuah job yang belum selesai bisa dianggap tersedia lagi jika batas waktunya terlampaui. Worker kedua lalu memproses job yang sama sementara worker pertama masih berjalan.

Intinya: jika durasi kerja job lebih lama dari timeout pengambilan pesan, duplikasi eksekusi bisa terjadi meskipun job awal sebenarnya belum gagal.

3. Race condition antar worker

Dua worker bisa memproses dua job berbeda yang mengubah resource yang sama, misalnya dua job sync invoice untuk invoice yang sama. Secara teknis ini bukan selalu “job yang sama”, tetapi efeknya sama: status data menjadi dobel, meloncat, atau tidak sinkron.

4. Crash setelah efek samping terjadi

Kasus klasik: job berhasil mengirim request ke payment gateway atau membuat record transaksi eksternal, tetapi worker mati sebelum menandai job selesai. Queue lalu me-retry job tersebut. Dari sudut pandang queue, retry itu benar. Dari sudut pandang bisnis, efek sampingnya sudah terjadi.

5. Lock atau cache yang kedaluwarsa terlalu cepat

Jika Anda memakai lock berbasis cache dengan TTL terlalu pendek, lock bisa habis saat job masih berjalan. Worker lain kemudian memperoleh lock baru dan memproses resource yang sama. Akibatnya, proteksi overlap gagal pada beban tinggi atau job lambat.

Locking, Unique Job, dan Idempotensi: Apa Bedanya?

Locking

Locking dipakai untuk mencegah dua proses berjalan bersamaan pada resource yang sama. Biasanya lock disimpan di Redis atau backend cache yang mendukung operasi atomik.

  • Cocok untuk: mencegah overlap antar worker pada entitas tertentu, misalnya satu order, satu user, satu invoice.
  • Kelebihan: sederhana, efektif untuk race condition.
  • Keterbatasan: tidak melindungi dari retry setelah crash jika efek samping sudah terjadi; TTL salah bisa membuka celah duplikasi.

Unique job

Unique job dipakai untuk mencegah job yang identik didispatch berkali-kali dalam periode tertentu. Di Laravel, ini relevan saat Anda ingin mencegah antrean dipenuhi job serupa sebelum job pertama diproses atau selesai.

  • Cocok untuk: deduplikasi di tahap dispatch.
  • Kelebihan: menurunkan beban queue dan mencegah spam job identik.
  • Keterbatasan: tidak otomatis membuat operasi aman jika job diproses ulang karena crash atau timeout.

Idempotensi

Idempotensi berarti menjalankan operasi yang sama berkali-kali tetap menghasilkan efek akhir yang sama. Ini adalah lapisan terakhir dan paling penting saat ada kemungkinan eksekusi ulang yang tidak bisa dihindari.

  • Cocok untuk: operasi yang punya efek samping, seperti membuat pembayaran, mengubah status order, sinkronisasi ke sistem eksternal.
  • Kelebihan: paling kuat terhadap retry, crash, dan duplikasi event.
  • Keterbatasan: butuh desain data yang disiplin, sering kali melibatkan constraint database atau tabel jejak idempotency key.

Aturan praktis: gunakan unique job untuk menahan duplikasi saat dispatch, lock untuk membatasi konkurensi saat eksekusi, dan idempotensi untuk menjaga kebenaran hasil akhir.

Pola Implementasi yang Praktis di Laravel

1. Gunakan unique job untuk deduplikasi saat dispatch

Jika sebuah resource tidak perlu memiliki beberapa job identik di antrean, gunakan job unik. Contoh: sinkronisasi order berdasarkan orderId.

namespace App\Jobs;

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 SyncOrderJob implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $uniqueFor = 300;

    public function __construct(public int $orderId)
    {
    }

    public function uniqueId(): string
    {
        return 'sync-order:' . $this->orderId;
    }

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

Pola ini berguna untuk mencegah banyak job identik masuk ke queue dalam waktu singkat. Namun, ini belum cukup bila job bisa dieksekusi ulang setelah crash.

2. Gunakan middleware WithoutOverlapping untuk mencegah overlap saat proses

Untuk job yang tidak boleh berjalan bersamaan pada entitas yang sama, gunakan middleware overlap. Ini relevan ketika beberapa job berbeda tetap mengarah ke resource yang sama.

namespace App\Jobs;

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 RecalculateCustomerBalanceJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $customerId)
    {
    }

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

    public function handle(): void
    {
        // hitung ulang saldo customer
    }
}

Mengapa ini bekerja? Middleware membuat worker kedua tidak memproses job yang memakai kunci overlap yang sama selama lock masih aktif. Ini mengurangi race condition antar worker.

Trade-off:

  • expireAfter terlalu pendek dapat membuka overlap jika job lama.
  • Nilai terlalu panjang dapat menahan pekerjaan lebih lama setelah crash.
  • releaseAfter mempengaruhi kapan job yang tertahan dicoba lagi; terlalu agresif bisa memicu antrean retry yang ramai.

3. Gunakan cache lock langsung untuk bagian kritikal

Untuk kontrol lebih rinci, Anda bisa memakai lock Redis melalui facade cache. Pendekatan ini cocok bila hanya sebagian kecil dari handle() yang perlu dilindungi.

use Illuminate\Support\Facades\Cache;
use App\Models\Order;

public function handle(): void
{
    $lock = Cache::lock('order-process:' . $this->orderId, 300);

    if (! $lock->get()) {
        $this->release(15);
        return;
    }

    try {
        $order = Order::findOrFail($this->orderId);

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

        // lakukan proses yang tidak boleh paralel
        $order->status = 'processed';
        $order->save();
    } finally {
        optional($lock)->release();
    }
}

Pola ini berguna bila Anda ingin memastikan satu order tidak diproses dua kali secara paralel. Tetap perhatikan bahwa lock bukan pengganti idempotensi. Jika proses eksternal sudah berhasil lalu worker crash sebelum status tersimpan, retry berikutnya tetap harus aman.

Idempotensi: Perlindungan Terakhir yang Paling Penting

Mengapa idempotensi perlu disimpan di database?

Cache lock bersifat sementara. Untuk efek samping bisnis, Anda membutuhkan jejak yang lebih tahan terhadap restart, failover, dan retry. Karena itu, banyak sistem menyimpan idempotency key di database dengan unique constraint.

Contoh skenario: job ChargePaymentJob dapat dicoba ulang. Anda ingin memastikan satu payment_request_id hanya menghasilkan satu transaksi final.

Contoh skema tabel idempotency

Schema::create('idempotency_keys', function ($table) {
    $table->id();
    $table->string('idempotency_key')->unique();
    $table->string('operation');
    $table->string('resource_type')->nullable();
    $table->unsignedBigInteger('resource_id')->nullable();
    $table->string('status'); // processing, completed, failed
    $table->json('response_payload')->nullable();
    $table->timestamps();
});

Contoh job dengan idempotency key

use App\Models\IdempotencyKey;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;

public function handle(PaymentGatewayClient $gateway): void
{
    DB::transaction(function () use ($gateway) {
        $key = IdempotencyKey::firstOrCreate(
            ['idempotency_key' => $this->idempotencyKey],
            [
                'operation' => 'charge-payment',
                'resource_type' => 'order',
                'resource_id' => $this->orderId,
                'status' => 'processing',
            ]
        );

        if ($key->status === 'completed') {
            return;
        }

        $existingPayment = Payment::where('order_id', $this->orderId)
            ->where('idempotency_key', $this->idempotencyKey)
            ->first();

        if ($existingPayment) {
            $key->update(['status' => 'completed']);
            return;
        }

        $result = $gateway->charge([
            'order_id' => $this->orderId,
            'amount' => $this->amount,
            'idempotency_key' => $this->idempotencyKey,
        ]);

        Payment::create([
            'order_id' => $this->orderId,
            'amount' => $this->amount,
            'external_id' => $result->transactionId,
            'idempotency_key' => $this->idempotencyKey,
            'status' => 'paid',
        ]);

        $key->update([
            'status' => 'completed',
            'response_payload' => json_encode([
                'transaction_id' => $result->transactionId,
            ]),
        ]);
    });
}

Poin penting:

  • Idempotency key harus stabil untuk operasi yang sama, misalnya payment:{order_id}:{attempt_group} atau request ID dari upstream.
  • Tabel bisnis juga sebaiknya punya unique constraint yang mendukung, misalnya unik pada idempotency_key atau kombinasi kolom yang relevan.
  • Jika sistem eksternal mendukung idempotency key, kirim key yang sama ke sana. Ini penting karena database lokal tidak bisa membatalkan efek samping yang sudah terjadi di luar sistem Anda.

Kapan status processing menjadi masalah?

Jika worker crash setelah menandai key sebagai processing tetapi sebelum selesai, retry berikutnya perlu tahu apakah boleh melanjutkan. Ada beberapa strategi:

  • Tambahkan timestamp dan anggap processing yang terlalu lama sebagai stale, lalu izinkan recovery.
  • Simpan informasi percobaan terakhir dan error terakhir.
  • Gabungkan dengan lock agar hanya satu recovery berjalan pada saat yang sama.

Menyelaraskan Timeout, Retry, dan Lock

Aturan praktis konfigurasi

  • Timeout worker/job harus realistis terhadap durasi proses normal plus margin.
  • retry_after atau mekanisme serupa sebaiknya lebih panjang dari durasi maksimum proses normal, agar job tidak dianggap hilang saat masih berjalan.
  • TTL lock harus lebih panjang dari bagian kritikal yang dilindungi.
  • Backoff retry sebaiknya tidak terlalu agresif agar tidak menumpuk saat dependency eksternal sedang lambat.

Kesalahan yang sering terjadi adalah memperkecil timeout agar failure cepat terdeteksi, tetapi lupa bahwa pekerjaan nyata kadang lambat karena API eksternal, query berat, atau kontensi Redis/database. Hasilnya, job aktif diambil ulang dan diproses ganda.

Trade-off konsistensi vs throughput

Semakin ketat proteksi Anda, semakin rendah peluang duplikasi, tetapi throughput bisa turun.

  • Lock per resource meningkatkan konsistensi, namun job untuk resource yang sama menjadi serial.
  • Unique job menurunkan spam antrean, tetapi bisa menahan update yang sebenarnya memang perlu dijalankan terpisah bila kunci terlalu kasar.
  • Idempotensi database paling aman, tetapi menambah query, constraint, dan kompleksitas recovery.

Pada sistem finansial atau inventori, biasanya konsistensi lebih penting. Pada sinkronisasi cache atau notifikasi, Anda bisa memilih throughput lebih tinggi dengan proteksi yang lebih ringan.

Skenario Failure dan Cara Menanganinya

1. Worker crash setelah API eksternal sukses

Gejala: payment terbuat di gateway, tetapi status lokal belum berubah lalu job di-retry.

Mitigasi: gunakan idempotency key yang sama ke gateway dan database lokal, lalu cek transaksi existing sebelum membuat record baru.

2. Lock expired saat job masih berjalan

Gejala: dua worker masuk ke bagian kritikal yang sama pada beban tinggi.

Mitigasi: naikkan TTL lock, pendekkan critical section, pindahkan operasi lambat non-kritikal ke luar area lock.

3. Retry storm saat dependency lambat

Gejala: antrean bertambah, job duplikat muncul, status terlambat sinkron.

Mitigasi: gunakan backoff bertahap, circuit breaker bila ada, dan tandai error transient vs permanen.

4. Status database tidak sinkron dengan efek samping eksternal

Gejala: order masih pending padahal pembayaran sudah berhasil.

Mitigasi: sediakan job rekonsiliasi periodik berdasarkan idempotency key atau external reference.

Observability dan Metrik yang Perlu Dipantau

Proteksi job ganda sulit dievaluasi jika Anda tidak punya data. Minimal, log dan metrik berikut sebaiknya tersedia:

Log terstruktur

  • job_class
  • job_id atau UUID
  • resource_id seperti order ID
  • idempotency_key
  • attempt
  • lock_key
  • worker / hostname / process id
  • duration_ms
  • result: success, released, skipped, duplicate, failed

Metrik penting

  • Jumlah job yang di-skip karena duplicate/idempotent hit
  • Jumlah lock contention
  • Durasi rata-rata dan persentil job lambat
  • Retry count per job class
  • Jumlah status processing yang stale pada tabel idempotency
  • Selisih antara event masuk dan efek bisnis final

Alert yang berguna

  • Lonjakan retry pada job tertentu
  • Banyak lock timeout atau contention
  • Antrean menumpuk bersamaan dengan durasi job naik
  • Rasio duplicate hit meningkat tiba-tiba

Jika Anda memakai Horizon atau alat observability lain, fokuskan dashboard pada attempt rate, durasi job, kegagalan per kelas job, dan pola release akibat overlap.

Kesalahan Umum yang Membuat Data Dobel

  • Menganggap queue menjamin exactly once. Pada praktiknya, yang umum adalah at least once.
  • Hanya memakai lock tanpa idempotensi untuk operasi yang memanggil sistem eksternal.
  • Membuat kunci lock terlalu umum sehingga throughput jatuh, atau terlalu spesifik sehingga race tetap lolos.
  • Tidak menambahkan unique constraint di database untuk entitas yang memang harus unik.
  • Menyimpan idempotency key di cache saja, lalu hilang saat restart.
  • Memakai TTL lock lebih pendek dari durasi kerja nyata.
  • Meng-update status bisnis di luar transaksi yang relevan tanpa strategi recovery.
  • Tidak membedakan error sementara dan error permanen, sehingga retry memproduksi efek samping baru.

Checklist Operasional

  1. Tentukan operasi mana yang harus idempotent: pembayaran, invoice, pengurangan stok, sinkronisasi status.
  2. Pastikan ada resource key yang jelas untuk lock, misalnya order:{id}.
  3. Gunakan ShouldBeUnique bila dispatch duplikat memang tidak diperlukan.
  4. Gunakan WithoutOverlapping atau Cache::lock() untuk resource yang tidak boleh diproses paralel.
  5. Simpan idempotency key di database dengan unique index.
  6. Tambahkan unique constraint pada tabel bisnis bila memungkinkan.
  7. Sesuaikan timeout, retry_after, dan TTL lock berdasarkan durasi kerja aktual.
  8. Log semua key penting: job id, resource id, idempotency key, attempt, result.
  9. Siapkan job rekonsiliasi untuk memperbaiki status yang tertinggal setelah crash.
  10. Uji skenario failure: worker kill, API timeout, lock expired, dan duplicate dispatch.

Strategi yang Disarankan untuk Sistem Nyata

Untuk mayoritas aplikasi Laravel, kombinasi berikut adalah titik awal yang aman:

  • Di tahap dispatch: pakai unique job jika event identik sering muncul berulang.
  • Di tahap eksekusi: pakai lock per resource untuk mencegah overlap antar worker.
  • Di tahap efek bisnis: pakai idempotency key yang persisten di database, plus unique constraint pada data penting.
  • Di sisi operasi: monitor retry, lock contention, dan stale processing record.

Jika harus memilih satu lapisan yang paling penting untuk mencegah dampak bisnis dari job ganda, pilihlah idempotensi. Lock dan unique job sangat berguna, tetapi keduanya tidak cukup bila proses sudah sempat menghasilkan efek samping lalu worker gagal sebelum mengakui keberhasilan.

Dengan desain seperti ini, Anda tidak sekadar “mengurangi kemungkinan” job ganda, tetapi juga membuat sistem tetap benar ketika duplikasi memang terjadi.