Debug Vercel Cron yang mengeksekusi job dua kali hampir selalu berujung pada pertanyaan yang sama: apakah scheduler memanggil dua kali, atau aplikasi kita memproses request yang sama lebih dari sekali? Dalam banyak kasus produksi, akar masalahnya bukan jadwal cron yang salah, melainkan endpoint pemicu yang tidak idempoten saat terjadi timeout, retry, atau dua request yang tiba hampir bersamaan.
Gejalanya sering menipu. Log aplikasi terlihat normal, tidak ada error fatal, tetapi database berisi data duplikat, invoice terbit dua kali, email terkirim ganda, atau penggunaan API pihak ketiga naik dan memengaruhi billing. Artikel ini membahas studi kasus backend nyata di Vercel: bagaimana job ganda bisa terjadi, cara menginvestigasinya dengan log dan korelasi event, serta perbaikan konkret agar masalah serupa tidak terulang.
Studi kasus: cron harian tampak sukses, tetapi efek sampingnya ganda
Bayangkan Anda punya endpoint serverless di Vercel yang dipanggil oleh cron setiap hari pukul 02:00 untuk menjalankan proses berikut:
- mengambil data tagihan yang jatuh tempo,
- membuat record penagihan,
- mengirim email notifikasi,
- menyimpan status bahwa job untuk tanggal itu sudah selesai.
Secara kasat mata, job ini tampak sederhana. Namun di produksi muncul gejala berikut:
- beberapa pelanggan menerima email yang sama dua kali,
- record penagihan duplikat muncul untuk tanggal yang sama,
- log menunjukkan dua request sukses dengan payload identik,
- dashboard scheduler tidak selalu menampilkan error yang jelas.
Masalah menjadi mahal ketika setiap eksekusi juga memanggil provider email, payment gateway, atau API berbayar. Satu retry yang tidak dikendalikan bisa berarti biaya ganda dan data yang harus dibersihkan manual.
Root cause: bukan satu hal, tetapi kombinasi beberapa kegagalan kecil
1. Serverless timeout membuat pemanggil menganggap request gagal
Pada arsitektur serverless, endpoint cron sering melakukan terlalu banyak pekerjaan secara sinkron: query besar, loop pengiriman email, pemanggilan API eksternal, lalu update status akhir. Jika eksekusi melambat atau mendekati batas waktu, pemanggil bisa menganggap request gagal atau tidak selesai tepat waktu.
Ketika pemanggil tidak menerima respons yang dianggap sukses, ia dapat melakukan retry. Dari sisi aplikasi, ini berarti dua request terpisah menjalankan logika yang sama.
2. Endpoint pemicu tidak idempoten
Retry aman hanya jika endpoint didesain idempoten. Tanpa idempotency key atau perlindungan deduplikasi, request kedua diperlakukan sebagai pekerjaan baru, padahal itu hanya pengulangan dari event yang sama.
Akibatnya:
- insert dilakukan dua kali,
- email dikirim dua kali,
- status diproses ulang,
- side effect ke sistem eksternal ikut terduplikasi.
3. Tidak ada unique constraint di database
Banyak tim mengandalkan pemeriksaan seperti "jika belum ada record, maka insert". Ini rapuh. Pada dua request paralel, keduanya bisa membaca keadaan yang sama-sama belum ada, lalu sama-sama menulis.
Ini bukan sekadar bug logika, tetapi race condition. Solusi yang kuat harus ditegakkan di level database, bukan hanya di kode aplikasi.
4. Race condition saat menyimpan status eksekusi
Pola yang sering bermasalah adalah:
- cek apakah job untuk tanggal tertentu sudah pernah jalan,
- jika belum, lanjut proses,
- setelah selesai, simpan status done.
Masalahnya, penanda status baru ditulis di akhir. Jika request kedua datang saat request pertama masih berjalan, keduanya sama-sama lolos pemeriksaan awal dan sama-sama memproses job.
Mengapa log tampak normal padahal hasilnya duplikat?
Ini salah satu bagian yang paling membingungkan saat debug Vercel Cron. Masing-masing request bisa terlihat sukses secara independen:
- request A mulai, memproses, lalu selesai,
- request B mulai beberapa detik kemudian, memproses, lalu selesai.
Tidak ada stack trace, tidak ada exception fatal, dan masing-masing request merasa melakukan pekerjaan yang sah. Jika Anda hanya melihat log per request tanpa korelasi, masalah ini terlihat seperti eksekusi normal.
Karena itu, debugging tidak cukup dengan mencari error. Anda perlu membuktikan bahwa dua request berbeda sebenarnya merepresentasikan event logis yang sama.
Langkah investigasi di produksi
1. Kumpulkan identifier yang konsisten
Pastikan setiap request dicatat dengan metadata minimum berikut:
- request ID internal aplikasi,
- timestamp saat request diterima dan saat tiap langkah penting berjalan,
- job key atau kunci bisnis, misalnya
billing:2026-08-31, - hasil operasi database utama,
- identifier dari pemanggil jika tersedia,
- durasi eksekusi.
Tanpa kunci bisnis yang sama di semua log, Anda sulit membedakan apakah dua request adalah dua event valid atau satu event yang diproses dua kali.
2. Korelasikan log berdasarkan job key, bukan hanya waktu
Misalnya Anda menemukan dua entri ini:
[02:00:01.120] req_7fa start job=billing:2026-08-31
[02:00:09.842] req_7fa create invoice customer=123
[02:00:11.201] req_8bc start job=billing:2026-08-31
[02:00:15.980] req_8bc create invoice customer=123
[02:00:18.410] req_7fa send email customer=123
[02:00:21.005] req_8bc send email customer=123
[02:00:23.310] req_7fa done job=billing:2026-08-31
[02:00:24.102] req_8bc done job=billing:2026-08-31Dari potongan ini, masalahnya bukan sekadar dua log sukses, melainkan dua request berbeda memproses job=billing:2026-08-31 yang sama. Inilah bukti duplikasi event.
3. Cocokkan dengan data di database
Periksa apakah data duplikat memiliki karakteristik yang sama:
- customer sama,
- periode sama,
- created_at berdekatan,
- dibuat oleh dua request ID berbeda.
Jika memungkinkan, simpan request_id atau idempotency_key pada record yang dibuat. Ini mempermudah audit setelah insiden.
4. Cari indikasi timeout atau retry
Perhatikan pola berikut:
- request pertama durasinya panjang atau berhenti tepat sebelum status akhir tercatat,
- request kedua datang beberapa detik setelah request pertama dimulai,
- dua request memiliki payload atau job key identik,
- sistem eksternal menerima dua panggilan dengan isi yang sama.
Anda tidak selalu akan melihat pesan eksplisit "retried". Kadang bukti terbaik adalah urutan timestamp dan kemiripan parameter eksekusi.
5. Uji hipotesis dengan replay terkontrol
Setelah dugaan kuat terbentuk, lakukan replay di lingkungan staging atau development:
- kirim dua request dengan job key yang sama secara hampir bersamaan,
- simulasikan timeout atau perlambatan sebelum respons dikirim,
- lihat apakah record dan side effect terduplikasi.
Jika sistem masih menghasilkan duplikasi, berarti endpoint memang belum idempoten.
Perbaikan konkret yang sebaiknya diterapkan
1. Gunakan idempotency key yang stabil per eksekusi logis
Untuk job terjadwal, idempotency key biasanya bisa diturunkan dari identitas pekerjaan, misalnya:
daily-report:2026-08-31billing:2026-08-31reminder:subscription-123:2026-08-31
Kuncinya harus merepresentasikan satu hasil bisnis yang diharapkan unik, bukan sekadar request HTTP tertentu.
Contoh handler sederhana:
export default async function handler(req, res) {
const jobDate = req.query.date;
const jobKey = `billing:${jobDate}`;
const inserted = await db.job_runs.insertIfAbsent({
job_key: jobKey,
status: 'processing',
started_at: new Date()
});
if (!inserted) {
return res.status(200).json({ ok: true, skipped: true, reason: 'duplicate job' });
}
try {
await runBillingForDate(jobDate, jobKey);
await db.job_runs.updateByJobKey(jobKey, {
status: 'done',
finished_at: new Date()
});
return res.status(200).json({ ok: true });
} catch (err) {
await db.job_runs.updateByJobKey(jobKey, {
status: 'failed',
error_message: String(err),
finished_at: new Date()
});
throw err;
}
}Poin pentingnya bukan sintaks, tetapi bahwa status processing dicoba ditulis di awal secara atomik. Request kedua harus gagal memperoleh hak eksekusi untuk job key yang sama.
2. Tambahkan unique constraint di database
Ini lapisan perlindungan paling penting. Jika satu invoice harus unik per pelanggan dan periode, paksa database menegakkannya.
CREATE UNIQUE INDEX uniq_invoices_customer_period
ON invoices (customer_id, billing_period);Dengan cara ini, sekalipun dua request lolos ke tahap penulisan, database tetap mencegah duplikasi data inti.
Catatan: validasi di aplikasi tetap berguna, tetapi tidak cukup untuk mencegah race condition. Unique constraint adalah pagar terakhir yang paling dapat diandalkan.
3. Pakai upsert atau transaksi untuk operasi kritis
Untuk state seperti job_runs, gunakan pola yang atomik. Hindari urutan select lalu insert jika keduanya tidak dilindungi transaksi atau constraint.
Contoh pendekatan yang lebih aman:
- upsert ketika ingin membuat record jika belum ada,
- insert dengan unique key lalu tangani konflik sebagai tanda duplikasi,
- transaksi untuk update beberapa tabel yang harus konsisten bersama.
Prinsipnya: keputusan "siapa yang berhak menjalankan job" harus ditentukan oleh operasi atomik, bukan oleh pembacaan state yang bisa basi beberapa milidetik kemudian.
4. Respons cepat, proses berat secara asynchronous
Jika endpoint cron melakukan banyak pekerjaan langsung dalam request yang sama, risiko timeout meningkat. Pola yang lebih aman adalah:
- endpoint menerima trigger,
- endpoint memvalidasi dan mendaftarkan job secara idempoten,
- endpoint segera mengembalikan respons sukses,
- pekerjaan berat dijalankan oleh worker, queue, atau proses async terpisah.
Ini mengurangi kemungkinan pemanggil mengira request gagal karena terlalu lama menunggu. Selain itu, retry pada tahap trigger menjadi lebih mudah ditangani karena trigger hanya melakukan enqueue yang idempoten, bukan seluruh bisnis proses.
Trade-off-nya, Anda perlu mekanisme pemantauan terpisah untuk memastikan job async benar-benar selesai. Namun untuk task yang mahal atau rentan timeout, pendekatan ini biasanya lebih aman daripada memproses semuanya sinkron.
5. Lindungi side effect eksternal
Bahkan jika record database aman, email atau panggilan API pihak ketiga masih bisa ganda jika tidak diberi perlindungan. Beberapa strategi:
- simpan log pengiriman berdasarkan idempotency key sebelum memanggil provider,
- gunakan identifier unik per event saat berkomunikasi dengan sistem eksternal jika didukung,
- cek apakah notifikasi untuk entitas dan periode yang sama sudah pernah dikirim.
Tujuannya adalah membuat side effect utama juga bersifat idempoten, bukan hanya penulisan ke database.
Contoh desain tabel untuk deduplikasi job
CREATE TABLE job_runs (
id BIGSERIAL PRIMARY KEY,
job_key TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP NULL,
request_id TEXT NULL,
error_message TEXT NULL
);Dengan tabel seperti ini, handler dapat mencoba membuat record job_key di awal. Jika gagal karena constraint unik, berarti job tersebut sudah atau sedang diproses.
Untuk banyak kasus, status yang cukup adalah:
processingdonefailed
Jika dibutuhkan, Anda bisa menambahkan attempt_count, last_seen_at, atau metadata lain untuk observability.
Observability agar insiden berikutnya lebih cepat terdeteksi
1. Log terstruktur
Hindari log bebas tanpa field tetap. Minimal catat:
request_id,job_key,step,duration_ms,result.
Dengan log terstruktur, Anda bisa mencari semua event berdasarkan job_key dan melihat apakah ada lebih dari satu request yang memprosesnya.
2. Metrik duplikasi dan konflik
Jangan hanya mengukur error. Tambahkan metrik seperti:
- jumlah request yang skipped karena job duplikat,
- jumlah konflik unique constraint,
- jumlah timeout pada endpoint trigger,
- jumlah side effect yang dibatalkan karena idempotency.
Konflik yang meningkat bisa menjadi sinyal dini adanya retry agresif atau performa yang menurun.
3. Simpan korelasi antar sistem
Jika job memanggil provider email atau billing, simpan ID request internal Anda bersama identifier dari provider tersebut. Saat ada komplain pelanggan, Anda bisa melacak dari efek eksternal kembali ke request asal.
Pengujian replay agar bug tidak muncul lagi
Setelah menerapkan perbaikan, jangan berhenti di unit test biasa. Buat pengujian yang meniru kondisi insiden:
1. Replay request yang sama dua kali
Kirim dua request dengan idempotency key identik dan pastikan:
- hanya satu job yang benar-benar diproses,
- request kedua mengembalikan status aman seperti skipped atau hasil yang sama,
- database tidak menghasilkan record ganda.
2. Uji konkurensi
Jalankan dua atau lebih pemanggilan secara paralel. Ini penting karena bug race condition sering tidak terlihat pada pengujian serial.
3. Simulasikan timeout parsial
Uji skenario ketika sebagian pekerjaan sudah sempat berjalan, lalu request dianggap gagal. Verifikasi bahwa replay berikutnya tidak menggandakan hasil bisnis.
4. Verifikasi side effect eksternal
Pastikan pengiriman email, pembuatan invoice, atau pemanggilan provider lain tidak terjadi dua kali walaupun trigger dikirim ulang.
Kesalahan umum yang sering menyebabkan job ganda
- menganggap cron scheduler pasti memanggil tepat satu kali tanpa retry,
- menyimpan status selesai hanya di akhir proses,
- mengandalkan check-then-insert tanpa constraint database,
- tidak menyimpan job key atau request ID di log,
- mencampur trigger dan pekerjaan berat dalam satu request sinkron,
- melindungi database tetapi lupa melindungi email atau API eksternal.
Kesimpulan
Pada kasus Debug Vercel Cron dengan job ganda, penyebab paling sering bukan cron yang "nakal", melainkan kombinasi timeout, retry webhook, endpoint yang tidak idempoten, dan race condition pada penyimpanan status. Karena setiap request bisa tampak sukses, investigasi harus berfokus pada korelasi request_id, timestamp, dan job_key untuk membuktikan bahwa satu event diproses lebih dari sekali.
Perbaikan yang efektif biasanya berlapis: gunakan idempotency key, tegakkan unique constraint di database, pakai upsert atau transaksi untuk claim eksekusi, kirim respons cepat lalu proses berat secara async, dan tingkatkan observability. Dengan kombinasi ini, retry tidak lagi menjadi ancaman, melainkan perilaku yang bisa ditoleransi dengan aman.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!