18 Des 2025·6 menit membaca

Menjadwalkan pekerjaan latar tanpa pusing dengan cron

Pelajari pola penjadwalan pekerjaan latar menggunakan workflow dan tabel jobs untuk menjalankan pengingat, ringkasan harian, dan pembersihan secara andal.

Menjadwalkan pekerjaan latar tanpa pusing dengan cron

Mengapa cron terasa mudah sampai tidak lagi

Cron nyaman pada hari pertama: tulis satu baris, pilih waktu, lupakan. Untuk satu server dan satu tugas, seringkali itu sudah cukup.

Masalah muncul saat Anda mengandalkan penjadwalan untuk perilaku produk yang nyata: pengingat, ringkasan harian, pembersihan, atau pekerjaan sinkronisasi. Sebagian besar cerita “run yang terlewat” bukan karena cron gagal. Mereka karena lingkungan di sekitarnya: reboot server, deploy yang menimpa crontab, job yang berjalan lebih lama dari perkiraan, atau mismatch jam/zona waktu. Dan ketika Anda menjalankan beberapa instance aplikasi, Anda bisa mendapatkan mode kegagalan sebaliknya: duplikat, karena dua mesin mengira mereka harus menjalankan tugas yang sama.

Testing juga kelemahan lain. Baris cron tidak memberi cara yang bersih untuk menjalankan “apa yang terjadi pada pukul 9:00 besok” dalam pengujian yang dapat diulang. Jadi penjadwalan berubah menjadi pemeriksaan manual, kejutan di produksi, dan perburuan log.

Sebelum memilih pendekatan, jelas kan apa yang Anda jadwalkan. Sebagian besar pekerjaan latar masuk ke beberapa kategori:

  • Pengingat (kirim pada waktu spesifik, hanya sekali)
  • Ringkasan harian (mengagregasi data, lalu kirim)
  • Tugas pembersihan (hapus, arsipkan, kedaluwarsa)
  • Sinkronisasi periodik (tarik atau dorong pembaruan)

Kadang Anda bisa melewatkan penjadwalan sama sekali. Jika sesuatu bisa terjadi segera saat sebuah event terjadi (pengguna mendaftar, pembayaran sukses, tiket berubah status), kerja berbasis event biasanya lebih sederhana dan lebih andal daripada kerja berbasis waktu.

Saat Anda membutuhkan waktu, keandalan sebagian besar terkait visibilitas dan kontrol. Anda ingin tempat untuk mencatat apa yang harus dijalankan, apa yang sudah dijalankan, dan apa yang gagal, plus cara aman untuk mencoba ulang tanpa menciptakan duplikat.

Pola dasar: scheduler, tabel jobs, worker

Cara sederhana untuk menghindari masalah cron adalah memisahkan tanggung jawab:

  • Scheduler memutuskan apa yang harus dijalankan dan kapan.
  • Worker melakukan pekerjaan.

Memisahkan peran membantu dua hal. Anda bisa mengubah penjadwalan tanpa menyentuh logika bisnis, dan mengubah logika bisnis tanpa merusak jadwal.

Sebuah tabel jobs menjadi sumber kebenaran. Alih-alih menyembunyikan state di dalam proses server atau baris crontab, setiap unit kerja adalah baris: apa yang dikerjakan, untuk siapa, kapan harus dijalankan, dan apa yang terjadi terakhir kali. Saat sesuatu salah, Anda bisa memeriksanya, mencoba ulang, atau membatalkannya tanpa menebak.

Alur tipikal terlihat seperti ini:

  • Scheduler memindai job yang jatuh tempo (misalnya, run_at <= now dan status = queued).
  • Ia mengklaim job sehingga hanya satu worker yang mengambilnya.
  • Worker membaca detail job dan melakukan aksi.
  • Worker mencatat hasil kembali ke baris yang sama.

Gagasan kuncinya adalah membuat pekerjaan dapat dilanjutkan, bukan magis. Jika worker crash di tengah jalan, baris job masih harus memberitahu apa yang terjadi dan apa yang harus dilakukan selanjutnya.

Merancang tabel jobs yang tetap berguna

Tabel jobs harus bisa menjawab dua pertanyaan dengan cepat: apa yang perlu dijalankan berikutnya, dan apa yang terjadi terakhir kali.

Mulailah dengan set kecil kolom yang mencakup identitas, waktu, dan progres:

  • id, type: id unik plus tipe singkat seperti send_reminder atau daily_summary.
  • payload: JSON tervalidasi dengan hanya apa yang dibutuhkan worker (mis. user_id, bukan objek user lengkap).
  • run_at: kapan job menjadi eligible untuk dijalankan.
  • status: queued, running, succeeded, failed, canceled.
  • attempts: diincrement setiap percobaan.

Tambahkan beberapa kolom operasional yang membuat konkurensi aman dan insiden lebih mudah ditangani. locked_at, locked_by, dan locked_until memungkinkan satu worker mengklaim job sehingga Anda tidak menjalankannya dua kali. last_error sebaiknya pesan singkat (dan opsional kode error), bukan dump stack trace penuh yang membengkakkan baris.

Akhirnya, simpan timestamp yang membantu tim support dan reporting: created_at, updated_at, dan finished_at. Ini memungkinkan menjawab pertanyaan seperti “Berapa banyak pengingat yang gagal hari ini?” tanpa menggali melalui log.

Index penting karena sistem Anda terus bertanya “apa selanjutnya?” Dua index yang biasanya worth it:

  • (status, run_at) untuk mengambil job jatuh tempo dengan cepat
  • (type, status) untuk memeriksa atau menjeda satu keluarga job saat ada masalah

Untuk payload, lebih baik gunakan JSON kecil dan terfokus serta validasi sebelum memasukkan job. Simpan identifier dan parameter, bukan snapshot data bisnis. Perlakukan bentuk payload seperti kontrak API sehingga job lama yang sudah antre tetap bisa berjalan setelah Anda mengubah aplikasi.

Siklus hidup job: status, locking, dan idempotensi

Job runner tetap andal ketika setiap job mengikuti siklus hidup kecil yang dapat diprediksi. Siklus ini adalah jaring pengaman Anda ketika dua worker mulai bersamaan, server restart di tengah jalan, atau Anda perlu mencoba ulang tanpa membuat duplikat.

Mesin status sederhana biasanya cukup:

  • queued: siap dijalankan pada atau setelah run_at
  • running: diklaim oleh worker
  • succeeded: selesai dan tidak boleh dijalankan lagi
  • failed: selesai dengan error dan perlu perhatian
  • canceled: dihentikan dengan sengaja (mis. pengguna berhenti berlangganan)

Mengklaim job tanpa kerja ganda

Untuk mencegah duplikat, klaim job harus atomik. Pendekatan umum adalah lock dengan timeout (lease): worker mengklaim job dengan mengatur status=running dan menulis locked_by serta locked_until. Jika worker crash, lock kadaluarsa dan worker lain bisa mengklaim kembali.

Aturan klaim praktis:

  • klaim hanya job yang queued dan run_at <= now
  • set status, locked_by, dan locked_until dalam update yang sama
  • klaim ulang job yang running hanya saat locked_until < now
  • jaga lease singkat dan perpanjang jika job panjang

Idempotensi (kebiasaan yang menyelamatkan Anda)

Idempotensi berarti: jika job yang sama berjalan dua kali, hasilnya tetap benar.

Alat paling sederhana adalah kunci unik. Misalnya, untuk ringkasan harian Anda bisa menegakkan satu job per pengguna per hari dengan kunci seperti summary:user123:2026-01-25. Jika insert duplikat terjadi, itu merujuk ke job yang sama, bukan membuat job kedua.

Tandai sukses hanya ketika efek samping benar-benar selesai (email dikirim, record diperbarui). Jika Anda mencoba ulang, jalur retry tidak boleh membuat email kedua atau write duplikat.

Retry dan penanganan kegagalan tanpa drama

Avoid technical debt in job systems
Hindari technical debt dengan mengekspor atau menghasilkan ulang kode sumber yang bersih saat kebutuhan berubah.
Generate Code

Retry adalah tempat sistem job menjadi dapat diandalkan atau berubah menjadi bising. Tujuannya sederhana: coba ulang saat kegagalan mungkin bersifat sementara, hentikan saat tidak.

Kebijakan retry default biasanya mencakup:

  • max attempts (misalnya, total 5 kali)
  • strategi delay (delay tetap atau exponential backoff)
  • kondisi berhenti (jangan retry error tipe “invalid input”)
  • jitter (offset acak kecil untuk menghindari lonjakan retry)

Alih-alih menciptakan status baru untuk retry, Anda sering bisa menggunakan kembali queued: set run_at ke waktu percobaan berikutnya dan masukkan job kembali ke antrean. Itu menjaga mesin status tetap kecil.

Saat job bisa membuat progres parsial, anggap itu normal. Simpan checkpoint sehingga retry bisa melanjutkan dengan aman, baik di payload job (contoh last_processed_id) atau di tabel terkait.

Contoh: job ringkasan harian menghasilkan pesan untuk 500 pengguna. Jika gagal pada pengguna ke-320, simpan ID pengguna terakhir yang berhasil dan retry dari 321. Jika Anda juga menyimpan record summary_sent per pengguna per hari, rerun bisa melewati pengguna yang sudah selesai.

Logging yang benar-benar membantu

Log secukupnya agar bisa debug dalam beberapa menit:

  • job id, tipe, dan nomor attempt
  • input kunci (user/team id, rentang tanggal)
  • timing (started_at, finished_at, next run time)
  • ringkasan error singkat (plus stack trace jika ada)
  • jumlah efek samping (email terkirim, baris diperbarui)

Langkah demi langkah: bangun loop scheduler sederhana

Get queue visibility fast
Bangun tampilan admin untuk memfilter queued, running, failed jobs dan jalankan ulang dengan aman.
Create Admin

Loop scheduler adalah proses kecil yang bangun pada ritme tetap, mencari pekerjaan jatuh tempo, dan menyerahkannya. Tujuannya adalah keandalan yang membosankan, bukan presisi sempurna. Untuk banyak aplikasi, "bangun setiap menit" sudah cukup.

Pilih frekuensi bangun berdasarkan seberapa sensitif waktu job dan seberapa banyak beban yang bisa ditangani database Anda. Jika pengingat harus hampir real-time, jalankan setiap 30–60 detik. Jika ringkasan harian bisa digeser sedikit, setiap 5 menit sudah cukup dan lebih murah.

Loop sederhana:

  1. Bangun dan ambil waktu saat ini (gunakan UTC).
  2. Pilih job jatuh tempo di mana status = 'queued' dan run_at <= now.
  3. Klaim job dengan aman sehingga hanya satu worker yang bisa mengambilnya.
  4. Serahkan setiap job yang diklaim ke worker.
  5. Tidur sampai tick berikutnya.

Langkah klaim adalah tempat banyak sistem rusak. Anda ingin menandai job sebagai running (dan menyimpan locked_by serta locked_until) dalam transaksi yang sama dengan memilihnya. Banyak database mendukung pembacaan “skip locked” sehingga beberapa scheduler bisa berjalan tanpa saling mengganggu.

-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at <= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;

Jaga ukuran batch kecil (mis. 50–200). Batch yang besar bisa memperlambat database dan membuat crash lebih menyakitkan.

Jika scheduler crash di tengah batch, lease melindungi Anda. Job yang macet di running menjadi eligible lagi setelah locked_until. Worker Anda harus idempoten sehingga job yang diklaim ulang tidak membuat email duplikat atau biaya ganda.

Pola untuk pengingat, ringkasan harian, dan pembersihan

Kebanyakan tim berakhir dengan tiga jenis pekerjaan latar yang sama: pesan yang harus keluar tepat waktu, laporan yang dijalankan terjadwal, dan pembersihan yang menjaga storage dan performa tetap sehat. Tabel jobs dan loop worker yang sama bisa menangani semuanya.

Pengingat

Untuk pengingat, simpan semua yang dibutuhkan untuk mengirim pesan di baris job: siapa penerima, channel (email, SMS, Telegram, in-app), template mana, dan waktu kirim tepatnya. Worker seharusnya bisa menjalankan job tanpa “mencari-cari” konteks tambahan.

Jika banyak pengingat jatuh tempo bersamaan, tambahkan rate limiting. Batasi pesan per menit per channel dan biarkan job ekstra menunggu run berikutnya.

Ringkasan harian

Ringkasan harian sering gagal saat jendela waktu tidak jelas. Pilih satu waktu cutoff yang stabil (misalnya, 08:00 di waktu lokal pengguna), dan definisikan jendelanya dengan jelas (mis. “kemarin 08:00 sampai hari ini 08:00”). Simpan cutoff dan zona waktu pengguna dengan job sehingga rerun menghasilkan hasil yang sama.

Jaga setiap job ringkasan kecil. Jika perlu memproses ribuan record, bagi menjadi chunk (per tim, per akun, atau berdasarkan rentang ID) dan antre job lanjutan.

Tugas pembersihan

Pembersihan lebih aman jika Anda memisahkan “hapus” dari “arsip”. Putuskan apa yang bisa dihapus selamanya (token sementara, session kadaluwarsa) dan apa yang harus diarsipkan (log audit, faktur). Jalankan pembersihan dalam batch yang dapat diprediksi untuk menghindari lock lama dan lonjakan beban mendadak.

Waktu dan zona waktu: sumber bug tersembunyi

Make jobs visible and controllable
Modelkan status, lock, dan retry dalam backend yang bisa Anda kembangkan tanpa penulisan ulang besar-besaran.
Build Now

Banyak kegagalan adalah bug waktu: pengingat terkirim satu jam lebih awal, ringkasan harian melewatkan Senin, atau pembersihan dijalankan dua kali.

Default yang baik adalah menyimpan timestamp penjadwalan dalam UTC dan menyimpan zona waktu pengguna secara terpisah. run_at Anda seharusnya satu momen UTC. Ketika pengguna bilang “09:00 waktu saya”, konversi itu ke UTC saat menjadwalkan.

Daylight saving time adalah tempat setup naif sering gagal. “Setiap hari jam 9:00” tidak sama dengan “setiap 24 jam”. Saat pergeseran DST, 09:00 lokal memetakan ke waktu UTC yang berbeda, dan beberapa waktu lokal tidak ada (spring forward) atau terjadi dua kali (fall back). Pendekatan yang lebih aman adalah menghitung kejadian lokal berikutnya setiap kali Anda menjadwalkan ulang, lalu konversi ke UTC lagi.

Untuk ringkasan harian, putuskan apa arti “sehari” sebelum menulis kode. Hari kalender (midnight sampai midnight di zona waktu pengguna) sesuai ekspektasi manusia. “24 jam terakhir” lebih sederhana tetapi menggeser dan mengejutkan orang.

Data terlambat tak terelakkan: sebuah event datang setelah retry, atau catatan ditambahkan beberapa menit setelah tengah malam. Putuskan apakah event terlambat masuk ke “kemarin” (dengan grace period) atau “hari ini”, dan konsistenlah pada aturan itu.

Buffer praktis bisa mencegah miss:

  • pindai job yang jatuh tempo sampai 2–5 menit lalu
  • buat job idempoten sehingga rerun aman
  • catat rentang waktu yang dicakup di payload sehingga ringkasan konsisten

Kesalahan umum yang menyebabkan run terlewat atau duplikat

Sebagian besar rasa sakit datang dari asumsi yang bisa diprediksi.

Yang terbesar adalah menganggap eksekusi “persis sekali”. Pada sistem nyata, worker restart, panggilan jaringan timeout, dan lock bisa hilang. Biasanya Anda mendapatkan delivery “setidaknya sekali”, yang berarti duplikat itu normal dan kode Anda harus tahan terhadapnya.

Kesalahan lain adalah melakukan efek samping dulu (kirim email, charge kartu) tanpa cek deduplikasi. Guard sederhana sering menyelesaikannya: timestamp sent_at, kunci unik seperti (user_id, reminder_type, date), atau token dedupe yang disimpan.

Visibilitas adalah celah berikutnya. Jika Anda tidak bisa menjawab “apa yang macet, sejak kapan, dan kenapa”, Anda akan menebak. Data minimal yang harus dekat adalah status, jumlah attempt, waktu run berikutnya, last error, dan worker id.

Kesalahan yang sering muncul:

  • mendesain job seolah dijalankan persis sekali, lalu terkejut dengan duplikat
  • menulis efek samping tanpa cek deduplikasi
  • menjalankan satu job besar yang mencoba melakukan semuanya dan terkena timeout di tengah
  • retry tanpa batas tanpa cap
  • melewatkan visibilitas antrean dasar (tidak ada pandangan backlog, failure, item yang berjalan lama)

Contoh konkret: job ringkasan harian melakukan loop pada 50.000 pengguna dan timeout pada pengguna ke-20.000. Saat retry, ia mulai dari awal dan mengirim ringkasan lagi ke 20.000 pertama kecuali Anda melacak penyelesaian per pengguna atau memecahnya menjadi job per-pengguna.

Daftar cek cepat untuk sistem job yang andal

Make scheduling testable
Uji alur “jalankan jam 9 besok” dengan memicu logika job yang sama secara manual.
Prototype

Job runner dianggap “selesai” saat Anda bisa mempercayainya jam 2 pagi.

Pastikan Anda memiliki:

  • Visibilitas antrean: hitungan queued vs running vs failed, plus job queued tertua.
  • Idempoten secara default: anggap setiap job bisa berjalan dua kali; gunakan kunci unik atau penanda “sudah diproses”.
  • Kebijakan retry per tipe job: retries, backoff, dan kondisi berhenti yang jelas.
  • Penyimpanan waktu konsisten: simpan run_at dalam UTC; konversi hanya saat input dan tampilan.
  • Lock yang dapat dipulihkan: lease sehingga crash tidak membuat job tetap running selamanya.

Juga batasi ukuran batch (berapa banyak job Anda klaim sekaligus) dan konkurensi worker (berapa banyak yang berjalan bersamaan). Tanpa batas, lonjakan bisa membebani database Anda atau membuat kerja lain kekurangan sumber daya.

Contoh realistis: pengingat dan ringkasan untuk tim kecil

Send reminders the safer way
Antre pengingat sekali jalan dengan kunci idempotensi untuk mengurangi duplikat dan kejutan.
Build Reminders

Alat SaaS kecil punya 30 akun pelanggan. Setiap akun ingin dua hal: pengingat jam 09:00 untuk tugas terbuka, dan ringkasan harian jam 18:00 tentang apa yang berubah hari itu. Mereka juga butuh pembersihan mingguan agar database tidak penuh dengan log lama dan token kadaluwarsa.

Mereka memakai tabel jobs plus worker yang mempoll untuk job jatuh tempo. Saat pelanggan baru mendaftar, backend menjadwalkan run pengingat dan ringkasan pertama berdasarkan zona waktu pelanggan.

Job dibuat pada beberapa momen umum: saat signup (buat jadwal berulang), pada event tertentu (antre notifikasi sekali jalan), pada tick penjadwalan (masukkan run mendatang), dan pada hari maintenance (antre pembersihan).

Suatu Selasa, penyedia email mengalami gangguan sementara pada 08:59. Worker mencoba mengirim pengingat, mendapat timeout, dan menjadwalkan ulang job itu dengan mengatur run_at menggunakan backoff (mis. 2 menit, lalu 10, lalu 30), mengincrement attempts tiap kali. Karena setiap job pengingat punya kunci idempotensi seperti account_id + date + job_type, retry tidak menghasilkan duplikat saat penyedia pulih di tengah proses.

Pembersihan dijalankan mingguan dalam batch kecil, sehingga tidak memblokir kerja lain. Alih-alih menghapus jutaan baris dalam satu job, ia menghapus sampai N baris per run dan menjadwalkan dirinya lagi sampai selesai.

Saat pelanggan mengeluh “Saya tidak menerima ringkasan,” tim memeriksa tabel jobs untuk akun dan tanggal itu: status job, jumlah attempt, field lock saat ini, dan last error yang dikembalikan oleh penyedia. Itu mengubah “seharusnya dikirim” menjadi “ini yang terjadi.”

Langkah selanjutnya: implementasikan, amati, lalu skalakan

Pilih satu tipe job dan bangun dari ujung ke ujung sebelum menambahkan lebih banyak. Job pengingat tunggal adalah starter yang baik karena menyentuh semuanya: penjadwalan, klaim pekerjaan jatuh tempo, mengirim pesan, dan mencatat hasil.

Mulai dengan versi yang bisa Anda percaya:

  • buat tabel jobs dan satu worker yang memproses satu tipe job
  • tambahkan loop scheduler yang mengklaim dan menjalankan job jatuh tempo
  • simpan payload secukupnya untuk menjalankan job tanpa tebak-tebakan tambahan
  • log setiap attempt dan hasil sehingga “Apakah ini dijalankan?” adalah pertanyaan 10 detik
  • tambahkan jalur rerun manual untuk job gagal sehingga pemulihan tidak memerlukan deploy

Setelah berjalan, buatlah dapat diamati oleh manusia. Bahkan tampilan admin dasar sangat berguna: cari job berdasarkan status, filter berdasarkan waktu, inspeksi payload, batalkan job yang macet, jalankan ulang job id tertentu.

Jika Anda lebih suka membangun alur scheduler dan worker jenis ini dengan logika backend visual, AppMaster (appmaster.io) dapat memodelkan tabel jobs di PostgreSQL dan mengimplementasikan loop claim-process-update sebagai Business Process, sekaligus menghasilkan kode sumber nyata untuk deployment.

Mudah untuk memulai
Ciptakan sesuatu yang menakjubkan

Eksperimen dengan AppMaster dengan paket gratis.
Saat Anda siap, Anda dapat memilih langganan yang tepat.

Memulai