September 27, 2026
Background Job & Queue.
Mengapa pekerjaan berat perlu dipindah ke antrean: producer/consumer, at-least-once delivery dan idempotensi, retry dengan exponential backoff, dead-letter, cron vs delayed job, serta concurrency. Contoh nyata dengan BullMQ, Laravel Queue, dan Android WorkManager.
Hampir setiap sistem backend yang tumbuh akan sampai pada momen ini: sebuah endpoint mulai melakukan terlalu banyak hal. Ia menyimpan data, mengirim email, memanggil API pihak ketiga, membuat thumbnail, dan mengirim notifikasi Discord, semua di dalam satu request HTTP yang ditunggu pengguna. Satu saja dari langkah itu lambat atau gagal, seluruh request ikut lambat atau gagal.
Background job memisahkan “menerima pekerjaan” dari “mengerjakannya”. Catatan ini membahas konsep di baliknya dan jebakan yang baru terlihat di produksi, dengan contoh dari Chat App Multiapps (BullMQ), Proxmox Management Server (task queue dengan progres real-time), Uptime Monitoring (cron + concurrency limit), dan NotiFly (Android WorkManager).
Mengapa Antrean?
Ada lima alasan utama, dan biasanya lebih dari satu berlaku sekaligus:
- Latensi respons. Pengguna tidak perlu menunggu email terkirim untuk melihat “Pesanan berhasil”.
- Ketahanan terhadap kegagalan sementara. API pihak ketiga yang sedang down tidak membuat request gagal. Job cukup dicoba lagi nanti.
- Meratakan lonjakan beban (load leveling). Seribu webhook yang tiba dalam satu detik tidak harus diproses dalam satu detik. Antrean menampungnya, dan worker memprosesnya dengan kecepatan yang sanggup ditangani database.
- Batas waktu dari pihak luar. Banyak platform (WhatsApp, Telegram, payment gateway) mengharapkan webhook dibalas cepat, dan akan mengirim ulang bila tidak. Memproses pesan langsung di handler webhook berisiko memicu retry dari platform dan duplikasi.
- Pekerjaan yang memang tidak dipicu request, seperti laporan harian, pembersihan data lama, atau pengecekan uptime setiap menit.
Anatomi: Producer, Broker, Consumer
┌──────────┐ enqueue ┌─────────────┐ ambil job ┌──────────┐
│ Producer │ ───────────▶ │ Broker │ ────────────▶ │ Consumer │
│ (webhook,│ │ (Redis, DB, │ │ (worker) │
│ API) │ │ SQS, ...) │ ◀──────────── │ │
└──────────┘ └─────────────┘ ack / fail └──────────┘
- Producer membuat job, yaitu data kecil yang mendeskripsikan pekerjaan (misalnya
{ inboxId, updateId }), lalu menaruhnya ke antrean. - Broker menyimpan job secara tahan lama. BullMQ memakai Redis, Laravel Queue bisa memakai Redis, database, SQS, dan lainnya, sedangkan WorkManager memakai database SQLite internal di perangkat.
- Consumer (worker) mengambil job, mengerjakannya, lalu melapor: sukses (ack) atau gagal.
Pemisahan ini membuat producer dan consumer bisa diskalakan, di-deploy, dan gagal secara independen.
Payload Job: Kirim ID, Bukan Objek
// Buruk: snapshot data ikut disimpan; bisa basi saat job dijalankan
await queue.add("send-invoice", { invoice: fullInvoiceObject });
// Baik: kirim referensi; worker membaca kondisi terbaru dari database
await queue.add("send-invoice", { invoiceId: 1234 });
Job bisa dijalankan detik ini, satu jam lagi (setelah beberapa retry), atau setelah diedit admin. Worker harus selalu membaca ulang kondisi terkini dan memutuskan apakah pekerjaannya masih relevan. Payload kecil juga menghemat memori broker.
Jaminan Pengiriman dan Idempotensi
Ini bagian terpenting, dan yang paling sering disalahpahami.
Ada tiga kemungkinan jaminan pengiriman:
| Jaminan | Arti | Konsekuensi |
|---|---|---|
| At-most-once | Dikirim paling banyak sekali | Job bisa hilang |
| At-least-once | Dikirim minimal sekali | Job bisa dijalankan lebih dari sekali |
| Exactly-once | Tepat sekali | Secara umum tidak bisa dijamin oleh broker saja |
Hampir semua sistem antrean praktis, termasuk BullMQ, Laravel Queue, dan WorkManager, memberikan at-least-once. Alasannya sederhana. Bayangkan worker selesai mengirim email, lalu crash sebelum sempat melapor “sukses” ke broker. Dari sudut pandang broker, job itu tidak pernah selesai, jadi ia diberikan lagi ke worker lain, dan email terkirim dua kali. Broker tidak punya cara membedakan “belum dikerjakan” dari “sudah dikerjakan tapi belum sempat melapor”.
Di BullMQ ini disebut stalled job. Worker memegang lock atas job dan harus memperbaruinya secara berkala. Bila lock tidak diperbarui (worker crash, atau event loop terblokir oleh kode CPU-intensif), job dikembalikan ke status waiting dan diproses lagi. Setelah melewati batas jumlah stall tertentu, job dipindah ke failed.
Kesimpulannya: Job Harus Idempoten
Karena duplikasi tidak bisa dihindari, job harus aman dijalankan lebih dari sekali. Menjalankannya dua kali harus menghasilkan efek yang sama dengan menjalankannya sekali.
Beberapa teknik:
1. Kunci natural + constraint unik di database.
CREATE TABLE messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
channel VARCHAR(32) NOT NULL,
external_id VARCHAR(128) NOT NULL, -- ID pesan dari platform
body TEXT,
UNIQUE KEY uq_channel_external (channel, external_id)
);
// Insert kedua dengan external_id yang sama akan ditolak database, bukan menggandakan
await prisma.message.upsert({
where: { channel_externalId: { channel: "telegram", externalId: String(update.update_id) } },
create: { channel: "telegram", externalId: String(update.update_id), body },
update: {},
});
Ini teknik yang paling kuat, karena jaminannya datang dari database, bukan dari kode yang bisa punya race condition.
2. Cek status sebelum bertindak. Worker di NotiFly membaca ulang entitas sebelum mengirim, dan keluar dengan tenang bila entitas sudah dihapus:
val entity = repository.getById(id) ?: return Result.success() // dihapus saat masih di antrean
3. Idempotency key ke pihak luar. Saat memanggil API yang mendukungnya (banyak payment API), kirim header kunci unik per operasi agar server tujuan menolak duplikasi. Bila server tujuan adalah milikmu sendiri, sertakan ID stabil di payload. NotiFly mengirim client_id (ID baris lokal) bersama setiap notifikasi, sehingga server bisa mendeteksi pengiriman ulang dari notifikasi yang sama.
4. Deduplikasi saat enqueue. Mencegah job ganda masuk ke antrean sejak awal. BullMQ punya opsi jobId kustom dan deduplication, Laravel punya ShouldBeUnique, dan WorkManager punya unique work. Ini mengurangi duplikasi, tetapi tidak menggantikan idempotensi di worker, karena duplikasi karena stall tetap bisa terjadi.
Komentar di kode queue Chatku merangkum trade-off ini dengan jujur:
Pengiriman jadi at-least-once. Job yang gagal SETELAH message tersimpan berpotensi menggandakan pesan saat retry; idempotency by updateId bisa ditambahkan menyusul bila perlu.
Dengan kata lain: begitu retry diaktifkan, idempotensi berhenti menjadi opsional.
Retry dengan Exponential Backoff
Tidak semua kegagalan sama. Klasifikasi yang tepat menentukan apakah retry masuk akal:
| Jenis | Contoh | Tindakan |
|---|---|---|
| Sementara (transient) | Timeout, 5xx, 429 rate limit, deadlock DB, koneksi putus | Retry dengan backoff |
| Permanen | 400 payload tidak valid, 401/403 kredensial salah, 404 resource hilang, konfigurasi kosong | Jangan retry; tandai gagal dan beri tahu |
NotiFly menerapkan klasifikasi ini secara eksplisit di repository:
return try {
val response = apiService.sendNotification(url, request)
when {
response.isSuccessful -> SendResult.Success
response.code() in 500..599 -> SendResult.Retryable("Server error ${response.code()}")
response.code() == 429 -> SendResult.Retryable("Rate limited (429)")
else -> SendResult.Permanent("HTTP ${response.code()}")
}
} catch (e: IOException) {
SendResult.Retryable(e.message ?: "Network error") // jaringan: coba lagi
} catch (e: Exception) {
SendResult.Permanent(e.message ?: "Unknown error") // bug/serialisasi: jangan diulang
}
Me-retry error permanen hanya membuang sumber daya dan menunda laporan kegagalan. Me-retry dengan jeda tetap yang pendek pada error sementara justru memperburuk keadaan: server tujuan yang sedang kewalahan diserang lagi oleh semua klien yang gagal.
Exponential backoff menggandakan jeda di setiap percobaan. Formula BullMQ adalah 2^(attempts - 1) × delay. Konfigurasi antrean pesan di Chatku:
const MESSAGE_QUEUE_OPTS = {
connection: REDIS_CONN,
defaultJobOptions: {
attempts: 5,
backoff: { type: "exponential", delay: 2000 }, // ~2s, 4s, 8s, 16s
removeOnComplete: 1000,
removeOnFail: 5000,
},
};
Konfigurasi ini lahir dari insiden nyata. Sebelumnya attempts dibiarkan default (1 kali), sehingga setiap blip kecil (deadlock MySQL, pool koneksi penuh saat beban tinggi, atau proses restart saat job sedang aktif) membuat pesan pelanggan hilang permanen. Gejala yang terlihat di produksi hanyalah “beberapa pesan hilang secara acak”, dan penyebabnya sulit dilacak karena tidak ada error yang tersisa.
Tambahkan jitter bila banyak job gagal bersamaan (misalnya semua job ke API yang sama saat API itu down). Tanpa jitter, semua job dijadwalkan ulang pada detik yang sama dan membentuk thundering herd baru. BullMQ mendukung opsi jitter (0 sampai 1) pada backoff.
Perhatikan juga removeOnComplete dan removeOnFail. Tanpa batas, histori job yang sudah selesai menumpuk di Redis tanpa akhir. Di Chatku jumlahnya sempat mencapai ribuan sebelum dibatasi. Simpan histori gagal lebih banyak daripada histori sukses, karena histori gagal yang dipakai untuk diagnosis.
Batas Total Percobaan
Retry harus berhenti di suatu titik. NotiFly membiarkan WorkManager mengatur jeda (exponential backoff, dimulai 30 detik), tetapi menghitung jumlah percobaan sendiri di database, agar server yang mati permanen tidak membuat item berputar selamanya:
is SendResult.Retryable -> {
repository.incrementRetryCount(id)
val attemptsUsed = entity.retryCount + 1
if (attemptsUsed >= MAX_ATTEMPTS) {
repository.markFailed(id, result.message)
notifyFailure(entity.title, result.message)
Result.failure()
} else {
repository.markPending(id, result.message) // tetap tampil "dalam antrean" di UI
Result.retry()
}
}
Menyimpan status (PENDING → SENDING → SENT / FAILED) di database lokal, bukan hanya di sistem antrean, memberi dua keuntungan: pengguna bisa melihat apa yang terjadi di layar History, dan item yang gagal bisa dikirim ulang secara manual kapan saja.
Dead-Letter: Tempat Job yang Menyerah
Setelah percobaan habis, job tidak boleh lenyap begitu saja. Ia harus mendarat di tempat yang terlihat, bisa diperiksa, dan bisa diproses ulang. Tempat ini disebut dead-letter queue (DLQ).
Setiap sistem punya bentuknya sendiri:
| Sistem | “Dead-letter” |
|---|---|
| BullMQ | Job tetap berada di failed set setelah percobaan habis; bisa di-retry manual. Untuk DLQ sungguhan, pindahkan ke antrean terpisah lewat event failed |
| Laravel | Tabel failed_jobs; php artisan queue:retry <id> untuk memproses ulang, dan method failed() pada job untuk reaksi kustom |
| WorkManager | Result.failure() mengakhiri work. “DLQ”-nya adalah status FAILED di database aplikasi sendiri, seperti di NotiFly |
Yang lebih penting dari tempatnya adalah ada yang memperhatikannya:
worker.on("failed", (job, err) => {
// Hanya ketika percobaan benar-benar habis, bukan setiap retry
if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) {
log.error({ err, jobId: job.id, queue: job.queueName }, "Job exhausted retries");
alerting.notify(`Job ${job.name} gagal permanen: ${err.message}`);
}
});
NotiFly menampilkan notifikasi sistem saat pengiriman gagal permanen. Uptime Monitoring mengirim alert ke Discord. Tanpa sinyal seperti ini, DLQ hanyalah tempat sampah yang tidak pernah dibuka.
Jangan Menelan Error Saat Enqueue
Ada satu titik kehilangan data yang sering terlewat: gagal memasukkan job ke antrean. Webhook Telegram dan WhatsApp di Chatku membalas HTTP 200 ke platform lebih dulu (agar platform tidak mengirim ulang), baru kemudian melakukan enqueue. Kalau Redis sedang tidak terjangkau, queue.add() melempar error setelah 200 sudah terkirim, dan pesannya lenyap tanpa jejak. Solusinya adalah pembungkus yang setidaknya mencatat kegagalan itu:
async function safeEnqueue(queue, jobName, data, log, context = {}) {
try {
await queue.add(jobName, data);
return true;
} catch (err) {
log?.error({ err, jobName, ...context }, "Webhook enqueue failed — message dropped");
return false;
}
}
Pola yang lebih kuat adalah transactional outbox: tulis event ke tabel database dalam transaksi yang sama dengan data bisnis, lalu proses terpisah memindahkannya ke antrean. Dengan cara ini, “data tersimpan” dan “job terjadwal” tidak bisa berbeda nasib.
Versi ringan dari masalah yang sama ada di Laravel. Job yang di-dispatch di dalam transaksi database bisa diambil worker sebelum transaksinya commit, sehingga worker tidak menemukan datanya. Gunakan ->afterCommit() atau opsi after_commit pada koneksi queue.
Penjadwalan: Cron vs Delayed Job
Ada dua jenis “nanti”:
| Cron / recurring | Delayed job | |
|---|---|---|
| Pemicu | Waktu kalender (“setiap hari 03:00”) | Kejadian + jeda (“30 menit setelah pesan terakhir”) |
| Jumlah | Satu jadwal, berulang | Satu job per kejadian |
| Contoh | Retensi data, laporan harian, cek uptime | Follow-up otomatis, pengingat invoice, pembatalan order tidak dibayar |
Cron yang Aman di Multi-Instance
Cron klasik
(node-cron, crontab) punya masalah ketika aplikasi berjalan di lebih dari satu instance: setiap instance menjalankan jadwal yang sama. Laporan harian terkirim tiga kali.
Solusinya adalah menjadikan penjadwal sebagai sumber job di broker bersama. Di BullMQ, jadwal yang berulang disimpan di Redis dan tidak ter-duplikasi meski setiap instance mendaftarkannya saat startup. Chatku masih memakai API repeat + jobId tetap:
await retentionQueue.add("prune", {}, {
repeat: { pattern: "0 3 * * *" }, // setiap hari 03:00
jobId: "data-retention-daily",
removeOnComplete: true,
removeOnFail: 50,
});
API repeat ini sudah deprecated sejak BullMQ 5.16.0 dan dihapus di v6, digantikan Job Schedulers:
await retentionQueue.upsertJobScheduler(
"data-retention-daily", // id scheduler; upsert = aman dipanggil berulang
{ pattern: "0 3 * * *" },
{ name: "prune", data: {} },
);
Di Laravel, jadwal didefinisikan di routes/console.php dan dijalankan oleh satu entri cron * * * * * php artisan schedule:run. Untuk multi-server, gunakan onOneServer() (membutuhkan cache driver bersama seperti redis atau database), dan withoutOverlapping() agar eksekusi yang lambat tidak menumpuk:
use App\Jobs\PruneOldData;
use Illuminate\Support\Facades\Schedule;
Schedule::job(new PruneOldData)
->dailyAt('03:00')
->onOneServer()
->withoutOverlapping();
Prinsip yang saya pakai: penjadwal hanya menentukan kapan, antrean yang mengerjakan. Command terjadwal dibuat setipis mungkin dan hanya men-dispatch job. Dengan begitu, pekerjaan yang dijadwalkan tetap mendapat retry, concurrency limit, dan visibilitas yang sama dengan job lain.
Cron In-Process: Kasus Uptime Monitoring
Uptime Monitoring memakai node-cron di dalam proses aplikasi, dan kodenya menunjukkan dua pengaman yang tetap perlu ada meski tanpa broker:
let isRunning = false;
cron.schedule("* * * * *", async () => {
if (isRunning) {
console.log("[CRON] Previous job still running, skipped");
return;
}
isRunning = true;
try {
const monitors = await Monitor.findAll({
order: [["last_checked", "ASC"]], // yang paling lama tidak dicek, lebih dulu
limit: dataCron,
});
const tasks = monitors
.filter(shouldRunMonitor) // hormati interval per-monitor
.map((m) => limit(() => runMonitor(m, timeout, retryCount)));
await Promise.all(tasks);
} finally {
isRunning = false;
}
});
- Flag
isRunningmencegah tick berikutnya mulai sebelum tick sebelumnya selesai. Tanpanya, pengecekan yang lambat (banyak endpoint timeout) akan menumpuk setiap menit. - Urut berdasarkan
last_checked+limitmembuat ratusan monitor diproses bergiliran secara adil.
Keterbatasannya, flag ini hanya berlaku di satu proses. Menjalankan dua instance berarti setiap endpoint dicek dua kali dan alert terkirim ganda. Begitu butuh lebih dari satu instance, pindahkan kunci ini ke Redis atau database, atau pindahkan jadwalnya ke broker.
Ada juga retry kecil di dalam job: setiap monitor dicoba ulang beberapa kali sebelum dinyatakan DOWN, untuk menghindari false positive dari gangguan sesaat. Alert hanya dikirim saat status berubah (UP → DOWN, atau DOWN → UP), bukan setiap kali cek gagal. Ini bentuk lain dari idempotensi: pemeriksaan yang berulang tidak menghasilkan notifikasi yang berulang.
Delayed Job dan Validasi Ulang
Follow-up otomatis di Chatku (“kirim pesan bila agen tidak membalas dalam N menit”) dijadwalkan sebagai delayed job:
await queue.add("followup", {
organizationId, ruleId: rule.id, conversationId: conversation.id,
anchorAt: message.createdAt.toISOString(), // titik acuan
waitFor: "agent",
}, { delay: minutes * 60 * 1000, removeOnComplete: 500, removeOnFail: 500 });
Kuncinya ada di anchorAt. Saat job akhirnya berjalan N menit kemudian, worker memeriksa ulang apakah sudah ada balasan setelah anchorAt. Kalau sudah, job selesai tanpa melakukan apa-apa. Ini jauh lebih sederhana dan andal daripada mencoba membatalkan delayed job setiap kali ada balasan masuk.
Pola umumnya: delayed job menyimpan niat, bukan keputusan. Keputusan final dibuat saat eksekusi, berdasarkan kondisi terkini.
Concurrency
Concurrency adalah jumlah job yang diproses bersamaan. Nilainya harus disesuaikan dengan sumber daya yang paling terbatas, yang biasanya bukan CPU worker.
new Worker("webchat", processor, { connection, concurrency: 10 });
new Worker("telegram", processor, { connection, concurrency: 5 });
new Worker("retention", processor, { connection, concurrency: 1 });
Pilihan angka di Chatku bukan kebetulan:
- Pesan kanal: 5 sampai 10. Job-nya didominasi I/O (database, API platform), jadi paralelisme meningkatkan throughput. Batasnya adalah ukuran pool koneksi database dan rate limit API platform.
- Retensi dan pelepasan assignment: 1. Job-nya berjalan lintas tenant dan menyentuh banyak baris. Menjalankan dua sekaligus hanya menimbulkan lock contention dan tidak mempercepat apa pun.
Beberapa hal yang perlu diperhatikan:
- Concurrency bersifat per worker, dan dikalikan jumlah instance.
concurrency: 5dengan 4 instance berarti 20 job paralel yang bersaing untuk pool database yang sama. - Rate limit pihak ketiga bersifat global. BullMQ mendukung limiter per antrean (misalnya maksimal N job per durasi), yang lebih tepat daripada mengecilkan concurrency.
- Urutan tidak dijamin dengan concurrency lebih dari 1. Dua pesan dari pelanggan yang sama bisa diproses tidak berurutan. Jika urutan penting, serialisasikan per kunci (misalnya per percakapan), bukan per antrean.
- Kode CPU-intensif memblokir event loop di Node.js, sehingga worker gagal memperbarui lock dan job dianggap stalled lalu diproses ulang. Pindahkan pekerjaan berat CPU ke sandboxed processor atau worker thread.
Di Uptime Monitoring, concurrency diatur dengan p-limit, bukan dengan antrean. Ratusan pengecekan HTTP dijalankan dengan paling banyak batch_size (default 10) koneksi paralel. Ini cara sederhana untuk mendapatkan concurrency limit tanpa broker, selama semuanya berjalan dalam satu proses.
Isolasi Antrean
Sebuah bug di Chatku mengajarkan pelajaran yang tidak terduga. BullMQ menyimpan data di Redis dengan prefix bull:<nama-antrean>. Dua proyek yang berbagi server Redis yang sama dan memakai nama antrean yang sama (telegram, email) akan berbagi antrean. Worker proyek A mengambil job proyek B, dan pesan tersimpan ke database yang salah. Gejalanya: pesan hilang secara berselang-seling.
Perbaikannya adalah memastikan konfigurasi koneksi menghormati nomor database Redis dari URL (redis://host:6379/1), sehingga setiap proyek punya ruang kunci sendiri. Opsi prefix di BullMQ adalah alternatif lainnya. Pelajarannya: nama antrean adalah bagian dari namespace global, jadi isolasi harus disengaja.
Laravel Queue
Laravel membungkus konsep yang sama dalam properti kelas job:
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Throwable;
class ProvisionService implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public $tries = 5; // total percobaan
public $backoff = [10, 30, 60, 120]; // jeda per percobaan (detik)
public $timeout = 120; // harus < retry_after di config/queue.php
public $uniqueFor = 3600;
public function __construct(public int $serviceId) {}
public function uniqueId(): string
{
return (string) $this->serviceId; // satu job aktif per layanan
}
public function middleware(): array
{
return [new WithoutOverlapping($this->serviceId)];
}
public function handle(): void
{
$service = Service::findOrFail($this->serviceId);
if ($service->status === 'active') {
return; // idempoten: sudah diprovisi oleh percobaan sebelumnya
}
// ... panggil API provisioning
}
public function failed(Throwable $e): void
{
// percobaan habis: beri tahu admin, catat ke log audit
}
}
Beberapa detail yang patut diingat:
$timeoutharus lebih kecil dariretry_afterkoneksi queue. Kalau tidak, job yang masih berjalan dianggap macet dan diberikan ke worker lain, sehingga dua worker mengerjakan job yang sama secara bersamaan. Dokumentasi Laravel menandainya sebagai peringatan penting.$backoffberbentuk array memberi jeda berbeda per percobaan. Ini exponential backoff yang ditulis tangan.$maxExceptionsmembedakan “gagal karena exception” dari “dilepas kembali secara sengaja” ($this->release(), misalnya saat terkena rate limit).ShouldBeUniquemencegah job duplikat masuk antrean, sedangkan middlewareWithoutOverlappingmencegah dua job dengan kunci sama berjalan bersamaan.- Job yang gagal permanen masuk ke tabel
failed_jobs.
Pola dengan idempotensi dan WithoutOverlapping seperti di atas adalah fondasi yang saya pakai saat merancang ulang otomasi billing hosting
di atas Laravel. Di domain itu, “double-charge” atau “double-provision” adalah bug yang paling mahal.
Android WorkManager: Antrean di Perangkat
Background job tidak hanya urusan server. Di Android, sistem operasi agresif mematikan proses di latar belakang demi baterai. WorkManager adalah API yang direkomendasikan untuk pekerjaan yang harus selesai meski aplikasi ditutup atau perangkat di-restart.
NotiFly memakainya untuk meneruskan notifikasi bank ke server. Alurnya:
NotificationListenerService
│ (filter lolos)
▼
Room: simpan PENDING ──▶ SendScheduler.enqueueSend(id)
│
▼
WorkManager (constraint: ada jaringan)
│
▼
SendNotificationWorker.doWork()
├─ Success → SENT
├─ Retryable → PENDING + Result.retry()
└─ Permanent → FAILED + notifikasi
Perhatikan urutannya: data disimpan dulu ke database lokal, baru di-enqueue. Ini outbox pattern versi mobile. Kalau pengiriman gagal atau aplikasi mati, data tetap ada dan bisa dikirim ulang.
Penjadwalannya:
fun enqueueSend(notificationId: Long) {
val request = OneTimeWorkRequestBuilder<SendNotificationWorker>()
.setInputData(workDataOf(SendNotificationWorker.KEY_NOTIFICATION_ID to notificationId))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30L, TimeUnit.SECONDS)
.build()
// Unik per notifikasi; REPLACE agar retry manual menggantikan percobaan yang masih antre
workManager.enqueueUniqueWork(
"send-notification-$notificationId",
ExistingWorkPolicy.REPLACE,
request,
)
}
Konsep-konsep dari bagian sebelumnya muncul lagi dengan nama berbeda:
| Konsep umum | WorkManager |
|---|---|
| Payload berisi ID | setInputData(workDataOf(KEY to id)) |
| Ack / retry / gagal | Result.success() / Result.retry() / Result.failure() |
| Exponential backoff | setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ...). Default-nya eksponensial 30 detik, minimum 10 detik |
| Deduplikasi saat enqueue | enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE / KEEP / APPEND, ...) |
| Cron | PeriodicWorkRequest, dengan interval minimum 15 menit |
| Prasyarat eksekusi | Constraints: jaringan, baterai, charging, storage |
Pruning histori memakai periodic work dengan kebijakan KEEP:
workManager.enqueueUniquePeriodicWork(
PruneHistoryWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP, // jadwal yang ada tetap dipakai saat app dibuka ulang
PeriodicWorkRequestBuilder<PruneHistoryWorker>(1, TimeUnit.DAYS).build(),
)
KEEP di sini penting. Tanpa itu, setiap kali aplikasi dibuka, jadwal di-reset dan hitungan 24 jam dimulai ulang. Pada pengguna yang sering membuka aplikasi, pruning tidak akan pernah berjalan.
Keterbatasan khas perangkat mobile juga perlu diperhitungkan:
- Eksekusi dibatasi sekitar 10 menit untuk worker biasa. Pekerjaan yang lebih panjang harus memakai
setForeground()(menjadi foreground service dengan notifikasi). - Waktu eksekusi tidak presisi. Doze mode dan optimisasi baterai OEM bisa menunda work. NotiFly menyediakan tab Access yang memandu pengguna memberi pengecualian optimisasi baterai dan izin autostart, karena beberapa vendor Android mematikan aplikasi latar belakang dengan sangat agresif.
- Pekerjaan dijadwalkan ulang setelah reboot. WorkManager menyimpan work di database internalnya sendiri, sehingga antrean bertahan meski perangkat di-restart.
Observabilitas
Antrean yang tidak diamati adalah tempat masalah bersembunyi. Metrik minimum yang perlu dipantau:
| Metrik | Kenapa penting |
|---|---|
| Kedalaman antrean (waiting) | Terus naik berarti consumer tidak sanggup mengikuti producer |
| Umur job tertua | Lebih bermakna daripada jumlah: 1.000 job berumur 1 detik tidak masalah, 10 job berumur 1 jam adalah masalah |
| Laju gagal dan isi failed set / DLQ | Deteksi dini integrasi yang rusak |
| Durasi job (p50/p95) | Job yang makin lambat mendekati timeout dan memicu stall |
| Jumlah stalled | Tanda event loop terblokir atau worker crash |
Untuk Laravel ada Horizon, sedangkan BullMQ bisa dipantau lewat event failed/stalled dan dashboard pihak ketiga. Untuk sistem kecil, log terstruktur yang menyertakan jobId, nama antrean, dan jumlah percobaan sudah jauh lebih baik daripada tidak ada apa-apa.
Checklist Desain Job
- Payload berisi ID, dan worker membaca ulang kondisi terkini.
- Job idempoten, sebaiknya dengan constraint unik di database.
- Error diklasifikasikan: sementara (retry) vs permanen (langsung gagal).
- Exponential backoff + jitter + batas total percobaan.
- Job gagal permanen terlihat (failed set,
failed_jobs, status FAILED) dan memicu alert. - Kegagalan enqueue tidak ditelan diam-diam; pertimbangkan outbox.
- Dispatch setelah commit transaksi.
- Jadwal berulang aman di multi-instance (scheduler di broker,
onOneServer, lock bersama). - Delayed job memvalidasi ulang kondisinya saat berjalan.
- Concurrency disesuaikan dengan sumber daya terbatas (pool DB, rate limit) dan dikalikan jumlah instance.
- Antrean terisolasi antar-proyek (DB index Redis atau prefix).
- Histori job dibatasi (
removeOnComplete/removeOnFail) agar broker tidak membengkak.
Kesimpulan
Antrean memindahkan pekerjaan keluar dari jalur request, tetapi tidak menghilangkan kompleksitasnya. Kompleksitas itu hanya berpindah ke tempat lain. Sebagai gantinya kita mendapatkan ketahanan: pekerjaan bertahan melewati crash, API yang down, dan lonjakan beban.
Harga yang harus dibayar adalah satu fakta yang tidak bisa ditawar: job akan dijalankan lebih dari sekali. Hampir semua pelajaran di catatan ini, mulai dari idempotensi, klasifikasi error, validasi ulang pada delayed job, sampai unique work di WorkManager, adalah variasi dari cara hidup berdamai dengan fakta itu. Rancang setiap job seolah-olah ia pasti akan diulang, karena cepat atau lambat, ia memang akan diulang.

Hey! I’m Fanny, the software engineer tending to this digital garden. You can read more about me, or subscribe by email.