Queue di Vercel perlu dirancang berbeda dibanding aplikasi yang memiliki proses worker persisten. Function di Vercel bersifat stateless, berdurasi terbatas, dan tidak cocok dijadikan consumer queue yang hidup terus-menerus. Karena itu, pola yang aman biasanya adalah: API menerima job, menyimpan status ke database, lalu pemrosesan dilakukan secara asynchronous melalui webhook, cron, atau worker/provider eksternal.
Masalah utamanya bukan sekadar bagaimana menjalankan job, tetapi bagaimana mencegah double processing, menangani retry tanpa efek samping ganda, menghindari job hilang, dan memastikan status job tetap akurat di lingkungan serverless. Artikel ini fokus pada implementasi praktis yang bisa diterapkan tanpa bergantung pada worker bawaan Vercel.
Mengapa queue di Vercel tidak bisa diperlakukan seperti worker tradisional
Pada sistem tradisional, queue biasanya memiliki satu atau lebih worker persisten yang terus melakukan polling atau menerima push dari broker seperti Redis, RabbitMQ, atau SQS. Worker menjaga koneksi, memproses pesan, mengatur concurrency, dan menerapkan retry dari proses yang relatif stabil.
Di Vercel, pendekatan itu tidak langsung cocok karena function:
- Stateless: tidak menyimpan state in-memory antar invokasi.
- Ephemeral: proses dapat dihentikan kapan saja setelah request selesai.
- Time-limited: tidak ideal untuk job panjang atau polling terus-menerus.
- Horizontal scale: invokasi paralel bisa memunculkan race condition bila state tidak dikontrol di database.
Implikasinya, queue di Vercel lebih aman jika diperlakukan sebagai job orchestration berbasis database dan trigger eksternal, bukan sebagai worker loop yang hidup terus.
Arsitektur yang realistis untuk queue di Vercel
Pola yang umum dan aman:
- Client memanggil API untuk membuat job.
- API menghasilkan idempotency key, menyimpan job ke database dengan status awal.
- Sistem mengirim sinyal pemrosesan, misalnya ke webhook internal, scheduler, atau provider queue eksternal.
- Processor mengambil job yang siap diproses.
- Processor mengunci job secara atomik, menjalankan pekerjaan, lalu memperbarui status.
- Jika gagal, job dijadwalkan ulang dengan retry dan backoff.
Diagram alur teks
Client
|
| POST /api/jobs (Idempotency-Key)
v
Vercel API Route
|
|-- simpan job ke DB (status=queued)
|-- simpan idempotency record
|-- trigger pemrosesan async
v
Database / Job Store
^
|
Processor (cron/webhook/provider worker)
|
|-- claim job secara atomik
|-- set status=processing, lock_until=...
|-- jalankan task
|-- success => status=completed
|-- failed => attempts++, next_run_at dihitung ulang
|-- fatal => status=dead_letter / failed_permanent
v
Observability (logs, metrics, trace, alert)Pilihan pemicu pemrosesan async
Tidak ada satu pola yang selalu benar. Pilih berdasarkan kebutuhan latensi, volume, dan kontrol operasional.
- Cron: sederhana untuk memproses job tertunda secara periodik. Cocok bila toleransi latensi dalam hitungan puluhan detik atau menit masih aman.
- Webhook internal: setelah job dibuat, API memicu endpoint processor secara asynchronous. Cocok untuk latensi lebih rendah, tetapi tetap perlu retry jika trigger gagal.
- Provider eksternal: gunakan layanan queue/worker di luar Vercel untuk konsumsi job. Cocok untuk beban tinggi, job berat, atau kebutuhan retry terkelola.
Poin pentingnya: database tetap menjadi sumber kebenaran status job, bukan status yang hanya hidup di memori function.
Skema tabel job yang aman untuk produksi
Skema minimal sebaiknya mendukung state machine, retry, deduplikasi, locking, dan audit operasional. Contoh kolom yang umum dipakai:
jobs
- id string/uuid primary key
- job_type string
- payload_json json/text
- status string -- queued | processing | completed | failed | dead_letter | cancelled
- priority integer -- opsional
- attempts integer
- max_attempts integer
- idempotency_key string -- unik per konteks yang relevan
- dedupe_key string -- opsional untuk menahan job identik
- lock_token string -- token pemroses saat claim
- lock_until timestamp
- next_run_at timestamp
- started_at timestamp
- finished_at timestamp
- last_error text
- result_json json/text
- created_at timestamp
- updated_at timestampStatus job yang disarankan
- queued: job sudah tercatat dan menunggu dieksekusi.
- processing: sedang diproses oleh satu worker.
- completed: sukses selesai.
- failed: gagal sementara dan masih bisa di-retry.
- dead_letter atau failed_permanent: gagal permanen setelah batas retry atau error fatal.
- cancelled: dibatalkan secara eksplisit sebelum dieksekusi.
Jika sistem Anda sederhana, status bisa diperkecil. Namun untuk debugging dan operasional, pemisahan status sementara dan permanen biasanya sangat membantu.
Tabel idempotency terpisah
Sering kali lebih aman menyimpan idempotency di tabel terpisah, terutama untuk request API yang bisa dipanggil ulang oleh client karena timeout jaringan.
idempotency_records
- idempotency_key string primary/unique
- request_hash string
- resource_type string -- mis. "job"
- resource_id string -- id job yang dibuat
- response_json json/text -- opsional, untuk replay respons yang sama
- created_at timestampDengan pendekatan ini, request yang sama tidak membuat job ganda walau endpoint terpanggil berulang.
Idempotensi, deduplikasi, dan mengapa keduanya berbeda
Dua konsep ini sering tercampur, padahal tujuannya berbeda:
- Idempotensi memastikan request yang sama tidak menghasilkan efek samping ganda.
- Deduplikasi mencegah job yang identik diproses berkali-kali, meskipun datang dari request berbeda.
Idempotency key
Gunakan header atau field seperti Idempotency-Key saat client membuat job. Saat API menerima request:
- Cek apakah key sudah ada.
- Jika sudah ada dan request cocok, kembalikan hasil sebelumnya atau referensi ke job yang sama.
- Jika belum ada, buat record idempotency dan job dalam satu transaksi bila memungkinkan.
Ini penting untuk kasus seperti:
- Client mengulang request karena respons timeout.
- Gateway atau proxy mengirim ulang request.
- User menekan tombol submit dua kali.
Dedupe key
Dedupe key dipakai ketika dua request berbeda secara teknis, tetapi semestinya merepresentasikan pekerjaan yang sama. Contoh: sinkronisasi invoice untuk invoice_id=123. Jika job dengan dedupe key itu masih queued atau processing, Anda bisa menolak job baru atau mengembalikan referensi ke job yang aktif.
Catatan: idempotency key biasanya berasal dari client atau request boundary. Dedupe key biasanya berasal dari domain bisnis, misalnya kombinasi entity ID dan jenis operasi.
Locking sederhana dan penanganan race condition
Masalah paling umum di queue serverless adalah dua processor mengklaim job yang sama hampir bersamaan. Ini bisa terjadi saat cron overlap, webhook dipanggil ulang, atau function scale out.
Prinsip klaim job yang aman
Jangan pernah mengambil job dengan pola:
1. SELECT job status=queued
2. proses di aplikasi
3. UPDATE status=processingPola itu rentan race condition. Dua invokasi bisa membaca baris yang sama sebelum salah satunya meng-update status.
Gunakan claim atomik di level database. Bentuknya tergantung database yang dipakai, tetapi prinsipnya sama: update hanya jika status masih valid dan lock sudah kosong atau kedaluwarsa.
// pseudo-code claim atomik
function claimNextJob(now, workerId) {
job = db.executeAtomically(`
pilih satu job yang:
status in ('queued', 'failed')
and next_run_at <= now
and (lock_until is null or lock_until < now)
urutkan berdasarkan priority desc, created_at asc
lalu update:
status = 'processing'
lock_token = workerId
lock_until = now + lease_duration
started_at = coalesce(started_at, now)
kembalikan job yang berhasil diupdate
`)
return job
}Dengan pola ini, hanya satu pemroses yang berhasil mengubah state job ke processing.
Lease lock, bukan lock permanen
Gunakan lock_until alih-alih asumsi bahwa processor akan selalu selesai dengan baik. Jika function crash atau timeout, lock akan kedaluwarsa dan job bisa diambil ulang. Ini lebih aman daripada lock boolean tanpa expiry karena lock bisa yatim (orphaned lock).
Perpanjangan lock untuk job panjang
Jika sebuah job mungkin berjalan lebih lama dari lease awal, processor perlu memperpanjang lock_until secara periodik. Namun bila durasi kerja terlalu panjang untuk serverless function, itu sinyal bahwa job lebih cocok dipindah ke worker dedicated.
Retry dengan backoff yang aman
Retry sebaiknya tidak dilakukan dengan loop sinkron di dalam satu function sampai sukses. Selain boros waktu eksekusi, ini berisiko timeout dan biaya tidak perlu. Simpan retry sebagai state di database.
Kapan job perlu di-retry
Retry cocok untuk error sementara, misalnya:
- HTTP 429 atau rate limit.
- Gangguan jaringan sementara.
- Database lock contention.
- Layanan pihak ketiga mengembalikan 5xx.
Retry tidak cocok untuk error fatal seperti payload invalid, referensi data tidak ada, atau pelanggaran aturan bisnis yang tidak akan membaik bila diulang.
Exponential backoff dengan jitter
Backoff mencegah sistem memukul layanan downstream secara serentak setelah terjadi gangguan. Tambahkan jitter agar retry tidak menumpuk di waktu yang sama.
function computeNextRun(attempts) {
baseSeconds = 30
maxSeconds = 3600
exponential = min(maxSeconds, baseSeconds * (2 ** attempts))
jitter = randomBetween(0, 15)
return now() + exponential + jitter
}Gunakan angka yang sesuai kebutuhan sistem Anda; yang penting adalah polanya, bukan angka pastinya.
Menyimpan hasil retry ke status job
function markFailure(job, error) {
retryable = isRetryable(error)
if (!retryable) {
update jobs set
status = 'dead_letter',
last_error = serialize(error),
finished_at = now(),
lock_token = null,
lock_until = null
where id = job.id and lock_token = currentWorker()
return
}
nextAttempts = job.attempts + 1
if (nextAttempts >= job.max_attempts) {
update jobs set
status = 'dead_letter',
attempts = nextAttempts,
last_error = serialize(error),
finished_at = now(),
lock_token = null,
lock_until = null
where id = job.id and lock_token = currentWorker()
} else {
update jobs set
status = 'failed',
attempts = nextAttempts,
next_run_at = computeNextRun(nextAttempts),
last_error = serialize(error),
lock_token = null,
lock_until = null
where id = job.id and lock_token = currentWorker()
}
}Penting: saat memperbarui hasil proses, sertakan kondisi lock_token = currentWorker() agar processor yang lock-nya sudah kedaluwarsa tidak menimpa hasil pemroses lain.
Pseudo-code alur end-to-end
1) API menerima job
function createJobHandler(request) {
idempotencyKey = request.headers['Idempotency-Key']
payload = validate(request.body)
requestHash = hash(payload)
existing = findIdempotencyRecord(idempotencyKey)
if (existing) {
if (existing.request_hash != requestHash) {
return error(409, 'Idempotency key sudah dipakai untuk payload berbeda')
}
return success(existing.response_json)
}
beginTransaction()
try {
// opsional: dedupe berdasarkan konteks bisnis
activeJob = findActiveJobByDedupeKey(makeDedupeKey(payload))
if (activeJob) {
saveIdempotencyRecord(idempotencyKey, requestHash, activeJob.id, { jobId: activeJob.id })
commit()
triggerAsyncProcessor()
return success({ jobId: activeJob.id, deduplicated: true })
}
job = insertJob({
job_type: 'send_email',
payload_json: payload,
status: 'queued',
attempts: 0,
max_attempts: 8,
idempotency_key: idempotencyKey,
dedupe_key: makeDedupeKey(payload),
next_run_at: now()
})
saveIdempotencyRecord(idempotencyKey, requestHash, job.id, { jobId: job.id })
commit()
} catch (e) {
rollback()
throw e
}
triggerAsyncProcessor()
return success({ jobId: job.id })
}2) Processor mengambil dan menjalankan job
function processOnce() {
workerId = newUuid()
job = claimNextJob(now(), workerId)
if (!job) return
try {
performSideEffectSafely(job)
update jobs set
status = 'completed',
result_json = buildResult(),
finished_at = now(),
lock_token = null,
lock_until = null
where id = job.id and lock_token = workerId
} catch (error) {
markFailure(job, error)
}
}3) Operasi side effect juga harus idempoten
Banyak sistem berhenti di idempotensi level API, padahal side effect downstream juga bisa dobel. Misalnya jika job mengirim request ke payment gateway atau service internal, sertakan identifier unik yang sama agar sistem tujuan juga bisa mendeteksi duplikasi.
function performSideEffectSafely(job) {
// contoh konseptual
externalApi.call({
operationId: job.id,
payload: job.payload_json
})
}Jika downstream tidak mendukung idempotensi, Anda perlu menyimpan catatan bahwa side effect tertentu sudah pernah dijalankan sebelum mengulangnya.
Masalah operasional yang paling sering terjadi
1) Double processing
Penyebab umum:
- Klaim job tidak atomik.
- Processor timeout setelah side effect berhasil tetapi sebelum status tersimpan.
- Retry terjadi padahal hasil sebelumnya sebenarnya sukses.
Mitigasi:
- Gunakan locking berbasis lease dan update bersyarat.
- Buat side effect downstream idempoten.
- Simpan jejak request/response eksternal bila perlu audit.
2) Job hilang
Biasanya terjadi ketika sistem hanya mengandalkan trigger tanpa job store yang durable. Misalnya request async sudah dikirim ke webhook processor, tetapi function pemroses gagal dipanggil dan tidak ada catatan bahwa job pernah dibuat.
Mitigasi:
- Simpan job ke database sebelum trigger pemrosesan.
- Gunakan cron sebagai safety net untuk memproses job
queued/failedyang tertinggal. - Buat query rekonsiliasi untuk mendeteksi job lama yang stagnan.
3) Cache status basi
UI sering melakukan polling status job. Jika endpoint status menggunakan cache yang tidak tepat, user bisa melihat job masih processing padahal sudah selesai.
Mitigasi:
- Untuk endpoint status job, prioritaskan pembacaan langsung ke sumber data yang konsisten.
- Jika memakai cache, gunakan TTL pendek dan invalidasi jelas.
- Jangan menyamakan cache-friendly content dengan endpoint status yang berubah cepat.
4) Timeout function
Serverless function bisa habis waktu saat memproses job berat, mengunggah file besar, atau menunggu API eksternal lambat.
Mitigasi:
- Pecah job besar menjadi langkah lebih kecil.
- Pindahkan workload CPU-bound atau durasi panjang ke worker dedicated.
- Jangan melakukan retry loop sinkron di satu invokasi.
5) Poison message
Poison message adalah job yang selalu gagal, misalnya payload korup atau bug deterministik. Tanpa penanganan, job ini akan terus memenuhi antrean retry.
Mitigasi:
- Batasi
max_attempts. - Pisahkan error retryable vs fatal.
- Pindahkan job ke
dead_lettersetelah batas tertentu. - Sediakan dashboard atau prosedur manual untuk inspeksi dan replay.
6) Observability yang lemah
Tanpa logging dan metrik yang baik, queue terlihat “jalan” sampai suatu hari job menumpuk dan tidak jelas di mana bottleneck-nya.
Minimal pantau:
- Jumlah job per status: queued, processing, failed, dead_letter.
- Job age: berapa lama job menunggu sebelum diproses.
- Retry rate dan distribusi attempts.
- Processing duration.
- Error rate per job_type.
- Stuck locks: job processing dengan
lock_untilsudah lewat.
Tambahkan correlation ID atau gunakan job.id sebagai pengikat log antar komponen.
Webhook, cron, atau worker dedicated: kapan memilih yang mana?
Webhook atau trigger langsung setelah enqueue
Pilih ini jika:
- Ingin latensi eksekusi cepat.
- Volume job belum terlalu tinggi.
- Task relatif singkat dan mostly I/O bound.
Keterbatasan:
- Masih perlu fallback jika trigger gagal.
- Bisa memicu ledakan concurrency jika enqueue masif.
Cron sebagai scheduler pemrosesan
Pilih ini jika:
- Task tidak harus dieksekusi seketika.
- Ingin desain sederhana dan mudah diaudit.
- Butuh mekanisme penyelamat untuk job yang tertinggal.
Keterbatasan:
- Latency bergantung interval cron.
- Jika throughput tinggi, satu tick cron bisa tidak cukup.
Provider eksternal atau worker dedicated
Pilih ini jika:
- Job panjang, berat, atau CPU-intensive.
- Butuh concurrency yang lebih terkontrol.
- Perlu retry, DLQ, scheduling, dan monitoring yang lebih matang.
Keterbatasan:
- Operasional lebih kompleks.
- Biaya dan integrasi bertambah.
- Menambah komponen yang harus diamankan dan dipantau.
Secara praktis, banyak tim memulai dengan pola database + webhook/cron, lalu memindahkan jenis job tertentu ke worker dedicated ketika volume atau durasinya meningkat.
Kesalahan implementasi yang sering terjadi
- Menganggap enqueue sama dengan sukses diproses. API sebaiknya mengembalikan
jobId, bukan menyatakan efek samping sudah selesai. - Tidak membedakan error retryable dan fatal. Akibatnya poison message diulang terus.
- Mengunci job tanpa expiry. Lock yatim akan membuat job macet permanen.
- Tidak membuat side effect idempoten. Retry yang benar di sisi queue tetap bisa menghasilkan duplikasi di downstream.
- Status job hanya disimpan di cache. Cache bukan sumber kebenaran queue.
- Tidak ada fallback scanner. Trigger yang gagal bisa menyebabkan job tertinggal selamanya.
Checklist implementasi aman di produksi
- Job store durable: simpan job di database sebelum trigger pemrosesan.
- Idempotency key untuk endpoint create job.
- Dedupe key untuk mencegah job bisnis identik berjalan ganda.
- Claim atomik saat mengambil job.
- Lease lock dengan expiry, bukan lock permanen.
- Retry dengan backoff + jitter.
- Pemisahan error retryable vs fatal.
- Dead-letter state untuk poison message.
- Conditional update dengan lock token agar worker usang tidak menimpa status.
- Fallback cron/scanner untuk job stagnan atau trigger gagal.
- Observability: log terstruktur, metrik antrean, alert backlog, dan korelasi per job.
- Endpoint status tanpa cache basi untuk data yang berubah cepat.
- Downstream idempotency bila job menghasilkan side effect eksternal.
- Runbook operasional untuk replay, cancel, dan investigasi dead-letter.
Penutup
Queue di Vercel tetap bisa dibangun dengan aman meski tidak ada worker persisten bawaan, asalkan desainnya menempatkan database sebagai pusat state dan memperlakukan eksekusi sebagai proses asynchronous yang bisa gagal, diulang, atau berjalan paralel. Kunci utamanya adalah idempotensi, deduplikasi, locking atomik, retry yang terukur, dan observability yang memadai.
Jika job Anda singkat dan dominan I/O, pola API + job store + webhook/cron sering sudah cukup. Namun bila durasi panjang, throughput tinggi, atau efek samping sangat sensitif, pertimbangkan memindahkan eksekusi ke worker dedicated sambil tetap menjaga state job dan idempotensi secara eksplisit.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!