Masalah job queue diproses dua kali hampir selalu muncul pada sistem yang memakai retry, timeout, atau worker yang bisa mati lalu hidup kembali. Di CodeIgniter 4, solusi yang aman bukan hanya menambahkan lock, tetapi menggabungkan idempotensi, status job di database, dan alur ack/retry yang konsisten.
Artikel ini membahas cara mencegah duplikasi job dengan pendekatan yang realistis: gunakan idempotency key untuk mengenali operasi yang sama, distributed lock untuk membatasi eksekusi paralel, dan constraint unik atau tabel deduplikasi agar duplikasi tetap tertahan walaupun lock gagal, TTL habis, atau worker restart di waktu yang buruk.
Mengapa job bisa diproses ganda?
Sebelum masuk ke implementasi, pahami dulu sumber masalahnya. Dalam sistem queue, at-least-once delivery adalah pola yang umum: broker atau aplikasi lebih memilih mengirim ulang job daripada kehilangan job. Akibatnya, satu job yang sama bisa diproses lebih dari sekali.
Skenario race condition yang umum
- Worker timeout sebelum ack: job sebenarnya sudah hampir selesai, tetapi worker dianggap gagal. Queue mengirim ulang job ke worker lain.
- Worker crash setelah menulis ke database: perubahan bisnis sudah tersimpan, tetapi ack belum terkirim. Saat retry, operasi dijalankan lagi.
- Dua worker mengambil payload logis yang sama: misalnya ada dua event pembayaran untuk order yang sama karena duplicate publish dari upstream.
- Lock TTL terlalu pendek: worker A masih berjalan, lock kedaluwarsa, lalu worker B masuk dan memproses job yang sama.
- Retry manual oleh operator: job di-requeue tanpa memeriksa apakah efek samping sebelumnya sudah terjadi.
Karena itu, anggapan bahwa "satu job = diproses satu kali" tidak aman. Yang perlu dijaga adalah efek bisnisnya hanya terjadi satu kali.
Prinsip desain: lock saja tidak cukup
Distributed lock berguna untuk mencegah dua worker aktif mengeksekusi operasi yang sama pada saat bersamaan. Namun lock saja tidak cukup karena:
- lock bisa kedaluwarsa sebelum proses selesai,
- cache/Redis bisa sementara tidak tersedia,
- worker bisa crash setelah melakukan perubahan tetapi sebelum melepas lock,
- duplikasi bisa datang bukan dari paralelisme, melainkan dari retry beberapa menit kemudian.
Karena itu, pola yang lebih aman adalah:
- Identifikasi operasi dengan idempotency key yang stabil.
- Simpan jejak eksekusi di database dengan status yang jelas.
- Gunakan lock untuk mengurangi race saat eksekusi paralel.
- Tegakkan keunikan dengan constraint unik atau tabel deduplikasi.
- Ack job hanya setelah commit berhasil dan status akhir tercatat.
Tujuan utamanya bukan mencegah retry, melainkan memastikan retry aman.
Arsitektur yang disarankan di CodeIgniter 4
Komponen inti
- Payload job berisi data bisnis dan
idempotency_key. - Tabel status job untuk melacak siklus hidup eksekusi.
- Lock service berbasis cache/Redis.
- Service processor yang menjalankan logika bisnis secara transaksional.
- Logging dan metrik untuk mendeteksi duplikasi dan retry abnormal.
Alur aman tingkat tinggi
- Worker menerima job dan mengambil
idempotency_key. - Cek tabel deduplikasi atau status job.
- Ambil lock berdasarkan kunci bisnis, misalnya
payment:{order_id}ataujob:{idempotency_key}. - Dalam transaksi database, tandai job sebagai
processingjika belum pernah sukses. - Jalankan efek bisnis utama.
- Simpan hasil, tandai
succeeded, lalu commit. - Baru setelah itu kirim ack ke queue.
Jika gagal di tengah jalan, biarkan queue me-retry. Pada retry berikutnya, idempotency key dan status di database akan mencegah efek ganda.
Desain data: status job dan tabel deduplikasi
Opsi 1: Satu tabel status job
Cocok bila Anda ingin audit yang lebih jelas terhadap setiap eksekusi.
CREATE TABLE job_executions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
queue_name VARCHAR(100) NOT NULL,
external_job_id VARCHAR(191) NULL,
idempotency_key VARCHAR(191) NOT NULL,
business_key VARCHAR(191) NULL,
status VARCHAR(32) NOT NULL,
attempts INT NOT NULL DEFAULT 0,
locked_until DATETIME NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL,
last_error TEXT NULL,
result_hash VARCHAR(191) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uniq_idempotency_key (idempotency_key)
);Catatan: nama kolom bisa disesuaikan. Intinya ada kunci unik pada idempotency_key dan status yang bisa dibaca ulang saat retry.
Opsi 2: Tabel deduplikasi terpisah
Pendekatan ini sering lebih sederhana jika yang penting hanya memastikan operasi bisnis tertentu tidak dieksekusi dua kali.
CREATE TABLE processed_operations (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
operation_key VARCHAR(191) NOT NULL,
operation_type VARCHAR(100) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uniq_operation_key (operation_key)
);Pola ini efektif untuk operasi seperti:
- membuat invoice dari
order_id, - menandai pembayaran dari
payment_provider_event_id, - mengirim email welcome untuk
user_id + template.
Kapan memakai constraint unik?
Jika operasi bisnis punya identitas alami yang unik, gunakan constraint unik di level database. Ini adalah pagar terakhir yang paling penting. Misalnya, satu order hanya boleh memiliki satu catatan capture pembayaran dari provider_reference. Walaupun dua worker lolos dari lock, database tetap menolak duplikasi.
Lock mengurangi race. Constraint unik mencegah korupsi data.
Menentukan idempotency key yang benar
Idempotency key harus merepresentasikan satu operasi bisnis yang sama, bukan sekadar satu percobaan eksekusi.
Contoh yang baik
payment_capture:{provider_event_id}invoice_generate:{order_id}email_send:{template}:{user_id}
Contoh yang buruk
- ID job queue internal yang berubah tiap retry.
- Timestamp saat job dibuat.
- UUID baru yang di-generate di worker.
Jika Anda membuat key baru pada setiap retry, idempotensi gagal total karena sistem menganggap semua retry adalah operasi baru.
Implementasi service di CodeIgniter 4
Di CodeIgniter 4, pisahkan logika menjadi service agar mudah diuji. Contoh berikut bersifat generik dan fokus pada pola, bukan pada implementasi library queue tertentu.
Contoh struktur service
<?php
namespace App\Services;
use CodeIgniter\Database\ConnectionInterface;
use Psr\Log\LoggerInterface;
class JobProcessorService
{
public function __construct(
protected ConnectionInterface $db,
protected LockService $lockService,
protected LoggerInterface $logger,
) {}
public function handle(array $payload): array
{
$idempotencyKey = $payload['idempotency_key'];
$businessKey = $payload['order_id'] ?? $idempotencyKey;
$lockKey = 'job:' . $idempotencyKey;
$lockTtl = 120;
$lock = $this->lockService->acquire($lockKey, $lockTtl);
if (! $lock->acquired) {
$this->logger->warning('Lock not acquired', [
'idempotency_key' => $idempotencyKey,
'lock_key' => $lockKey,
]);
throw new \RuntimeException('Job is being processed by another worker');
}
try {
return $this->processWithTransaction($payload, $businessKey, $idempotencyKey);
} finally {
$this->lockService->release($lock);
}
}
protected function processWithTransaction(array $payload, string $businessKey, string $idempotencyKey): array
{
$this->db->transBegin();
try {
$existing = $this->db->table('job_executions')
->where('idempotency_key', $idempotencyKey)
->get()
->getRowArray();
if ($existing && $existing['status'] === 'succeeded') {
$this->db->transCommit();
return [
'status' => 'duplicate_ignored',
'message' => 'Job already succeeded',
];
}
if (! $existing) {
$this->db->table('job_executions')->insert([
'queue_name' => 'default',
'idempotency_key' => $idempotencyKey,
'business_key' => $businessKey,
'status' => 'processing',
'attempts' => 1,
'started_at' => date('Y-m-d H:i:s'),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
} else {
$this->db->table('job_executions')
->where('id', $existing['id'])
->update([
'status' => 'processing',
'attempts' => $existing['attempts'] + 1,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
$result = $this->executeBusinessOperation($payload);
$this->db->table('job_executions')
->where('idempotency_key', $idempotencyKey)
->update([
'status' => 'succeeded',
'finished_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$this->db->transCommit();
return [
'status' => 'succeeded',
'result' => $result,
];
} catch (\Throwable $e) {
$this->db->transRollback();
$this->db->table('job_executions')
->where('idempotency_key', $idempotencyKey)
->update([
'status' => 'failed',
'last_error' => $e->getMessage(),
'updated_at' => date('Y-m-d H:i:s'),
]);
$this->logger->error('Job processing failed', [
'idempotency_key' => $idempotencyKey,
'error' => $e->getMessage(),
]);
throw $e;
}
}
protected function executeBusinessOperation(array $payload): array
{
// Contoh: update order, simpan payment, kirim event lanjutan.
// Pastikan operasi ini juga punya pagar idempotensi di level data.
return ['ok' => true];
}
}Poin penting dari contoh di atas:
- Lock diambil sebelum eksekusi untuk mengurangi pemrosesan paralel.
- Status dicek di database agar retry tidak mengulang operasi yang sudah sukses.
- Transaksi database menjaga perubahan bisnis dan status job tetap konsisten.
- Release lock di blok
finallyagar lock dilepas walaupun ada exception.
Contoh LockService berbasis cache/Redis
Implementasi tepatnya bergantung pada adapter cache yang Anda pakai. Secara konsep, lock perlu memiliki:
- key yang unik,
- owner token agar hanya pemilik yang boleh melepas lock,
- TTL untuk mencegah lock yatim saat worker mati.
<?php
namespace App\Services;
class LockHandle
{
public function __construct(
public bool $acquired,
public string $key,
public ?string $owner = null,
) {}
}
class LockService
{
public function acquire(string $key, int $ttlSeconds): LockHandle
{
// Pseudo-code.
// Dengan Redis, pola umum adalah SET key owner NX EX ttl.
$owner = bin2hex(random_bytes(16));
$acquired = true; // ganti dengan hasil operasi cache nyata
return new LockHandle($acquired, $key, $owner);
}
public function release(LockHandle $lock): void
{
if (! $lock->acquired) {
return;
}
// Hapus lock hanya jika owner cocok.
// Ini mencegah worker lama menghapus lock milik worker baru.
}
}Walaupun pseudo-code, prinsipnya penting: jangan pernah melepas lock tanpa memverifikasi owner.
Alur ack/retry yang aman
Urutan ack sangat menentukan apakah retry akan aman atau justru membuat duplikasi.
Pola yang disarankan
- Ambil job dari queue.
- Proses dengan idempotensi + lock + transaksi.
- Jika commit berhasil, ack job.
- Jika gagal sebelum commit, jangan ack; biarkan queue me-retry.
Kenapa ack terlalu awal berbahaya?
Jika job di-ack sebelum perubahan bisnis selesai disimpan, lalu worker crash, job hilang tetapi efek bisnis belum lengkap. Ini bukan duplikasi, tetapi kehilangan data. Sebaliknya, jika ack terlalu akhir tanpa idempotensi, retry bisa menimbulkan efek ganda. Karena itu, ack setelah state final tersimpan adalah kompromi yang aman.
Bagaimana jika crash setelah commit tetapi sebelum ack?
Ini skenario klasik. Queue akan mengirim ulang job, tetapi saat diproses ulang:
- idempotency key yang sama ditemukan,
- status di database sudah
succeeded, - handler cukup mengembalikan hasil
duplicate_ignoredlalu ack.
Inilah alasan kenapa idempotensi harus bertumpu pada state yang persisten, bukan hanya memori worker.
Strategi TTL lock yang realistis
TTL lock harus lebih panjang dari durasi normal eksekusi job, tetapi tidak terlalu panjang sampai membuat recovery lambat saat worker mati.
Panduan praktis
- Set TTL berdasarkan worst-case yang masuk akal, bukan rata-rata.
- Jika job bisa berjalan lama, pertimbangkan heartbeat atau refresh lock berkala.
- Tambahkan buffer untuk latency jaringan, beban database, dan retry internal.
- Jangan menyamakan TTL lock dengan timeout HTTP; ukur dari durasi kerja worker.
Risiko TTL terlalu pendek
- lock habis saat job masih aktif,
- worker kedua masuk dan menjalankan operasi yang sama,
- terjadi duplikasi walaupun lock dipakai.
Risiko TTL terlalu panjang
- job berikutnya tertahan lama setelah worker crash,
- operator mengira queue macet padahal masih menunggu lock basi habis.
Jika operasi sangat kritikal, andalkan lock hanya sebagai optimasi konkurensi, lalu biarkan unique constraint menjadi pengaman final.
Kombinasi terbaik: lock + deduplikasi + constraint unik
Pendekatan yang paling tahan terhadap kondisi nyata adalah menggabungkan beberapa lapisan proteksi.
Lapisan 1: Lock
Mencegah dua worker mengeksekusi operasi yang sama pada saat bersamaan.
Lapisan 2: Tabel deduplikasi atau status job
Memberi memori persisten bahwa operasi ini pernah diproses, sedang diproses, gagal, atau sudah sukses.
Lapisan 3: Constraint unik pada data bisnis
Menjaga database tetap konsisten jika dua lapisan di atas gagal karena race, TTL, crash, atau kesalahan manual.
Contoh nyata: pada job capture pembayaran, Anda bisa:
- lock berdasarkan
provider_event_id, - simpan
idempotency_keydijob_executions, - beri unique index pada tabel pembayaran untuk
provider_reference.
Kalau worker A dan B sama-sama lolos ke tahap insert, salah satunya akan ditolak database. Handler harus memperlakukan error unik ini sebagai sinyal operasi kemungkinan sudah selesai, lalu membaca ulang data final, bukan langsung menganggap sistem rusak.
Common mistake yang sering terjadi
- Menggunakan ID pesan queue sebagai idempotency key. Pada banyak sistem, retry bisa menghasilkan envelope atau delivery baru.
- Menyimpan status hanya di memori. Setelah restart, semua jejak hilang.
- Tidak memeriksa status succeeded saat retry. Akibatnya operasi tetap dijalankan ulang.
- Release lock tanpa owner token. Worker lama bisa melepas lock yang sudah diambil worker baru.
- Tidak ada unique constraint di data bisnis kritikal. Lock dan cache tidak boleh menjadi satu-satunya pagar.
- TTL lock terlalu agresif. Ini sering memicu race yang sulit direproduksi di lokal.
Logging dan metrik yang perlu ada
Duplikasi job di production sulit ditangani tanpa observabilitas. Minimal, setiap log terkait job harus membawa korelasi yang konsisten.
Field log yang disarankan
idempotency_keyexternal_job_idbusiness_keyattemptworker_idatau hostnamelock_keystatus_beforedanstatus_afterduration_msjika tersedia
Metrik yang berguna
- jumlah lock acquisition gagal,
- jumlah retry per jenis job,
- jumlah duplicate ignored,
- durasi eksekusi per job,
- jumlah error constraint unik,
- jumlah job stuck di status
processing.
Lonjakan duplicate_ignored biasanya menandakan ada masalah di upstream publisher, timeout worker, atau timeout ack.
Strategi debugging job duplikat di production
Checklist investigasi
- Telusuri idempotency key yang sama di log dan database.
- Periksa urutan waktu: kapan status jadi
processing, kapan commit, kapan ack, kapan retry masuk. - Cek lock TTL: apakah habis sebelum job selesai?
- Lihat attempts: apakah retry terlalu cepat atau terlalu sering?
- Periksa constraint unik: apakah ada insert ganda yang seharusnya tertolak tetapi ternyata tidak ada unique index?
- Identifikasi sumber duplikasi: broker, publisher upstream, manual replay, atau worker restart.
- Audit transisi status: apakah ada job yang meloncat dari
processingke retry tanpafailedyang jelas?
Tanda-tanda akar masalah tertentu
- Banyak retry dengan status sudah succeeded: kemungkinan crash setelah commit sebelum ack.
- Dua eksekusi berjalan paralel dalam rentang detik: lock gagal, lock TTL habis, atau key lock terlalu granular.
- Dua row bisnis identik lolos: tidak ada unique constraint yang memadai.
- Status processing menumpuk: worker mati tanpa recovery, atau kode gagal memperbarui status akhir.
Pola implementasi untuk operasi bisnis kritikal
Untuk operasi seperti pembayaran, pengurangan stok, atau pembuatan invoice, gunakan pola berikut:
- Tentukan business key yang stabil.
- Generate atau teruskan idempotency key dari sumber event.
- Ambil lock berdasarkan business key atau idempotency key.
- Masuk ke transaksi database.
- Cek apakah operasi sudah pernah sukses.
- Jika belum, lakukan perubahan bisnis.
- Tulis status
succeeded. - Commit.
- Baru lakukan ack.
Jika ada panggilan ke sistem eksternal yang tidak bisa dibungkus dalam satu transaksi database, desainnya menjadi lebih rumit. Dalam kasus ini, simpan dulu niat dan status lokal, lalu gunakan pola kompensasi, outbox, atau rekonsiliasi. Intinya tetap sama: sistem harus bisa mengenali bahwa operasi yang sama tidak boleh dieksekusi dua kali.
Kesimpulan
Untuk CodeIgniter 4: cegah duplikasi job dengan idempotensi dan lock, pendekatan yang aman adalah kombinasi beberapa lapisan, bukan satu trik tunggal. Idempotency key memberi identitas operasi, distributed lock mengurangi race antar-worker, status job di database membuat retry aman, dan constraint unik atau tabel deduplikasi menjaga integritas data saat kondisi buruk benar-benar terjadi.
Jika Anda harus memilih prioritas implementasi, urutannya biasanya:
- pastikan ada idempotency key yang benar,
- simpan status persisten di database,
- tambahkan unique constraint pada data bisnis kritikal,
- baru optimalkan dengan lock berbasis Redis/cache.
Dengan urutan ini, retry, timeout, dan worker restart tidak lagi identik dengan duplikasi efek bisnis.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!