Deno Queue Worker tidak harus berarti sistem yang rumit. Untuk banyak aplikasi backend, kebutuhan utamanya sederhana: proses job background dengan aman, hindari eksekusi ganda, tahan terhadap crash, dan jaga cache tetap konsisten tanpa langsung menambah message broker besar.

Artikel ini membahas desain worker berbasis queue di Deno dengan pendekatan yang pragmatis. Fokusnya bukan meniru stack enterprise, melainkan memanfaatkan runtime yang sederhana untuk menangani masalah nyata: duplicate job, race condition, retry, dead-letter, visibility timeout, cache invalidation, dan eventual consistency.

Kapan queue worker di Deno sudah cukup?

Gunakan worker sederhana saat Anda punya pola seperti ini:

  • API menerima request pengguna, lalu sebagian pekerjaan dipindah ke proses background.
  • Job tidak harus selesai dalam request utama, misalnya kirim email, sinkronisasi data, generate report, refresh cache, atau webhook retry.
  • Volume belum menuntut cluster broker khusus.
  • Anda ingin operasional yang ringan: satu runtime, sedikit komponen, mudah di-debug.

Pada tahap ini, kombinasi Deno + Postgres atau Deno + Redis sering sudah cukup. Menambah Kafka, RabbitMQ, atau sistem queue besar terlalu dini biasanya menambah kompleksitas: deployment, monitoring, retry semantics, backpressure, dan debugging lintas komponen.

Prinsip praktis: jika kebutuhan Anda masih bisa dijelaskan sebagai “ambil job, kunci, proses, retry bila gagal, tandai selesai, dan perbarui cache”, jangan buru-buru memasang broker besar.

Masalah operasional yang harus diselesaikan sejak awal

1. Duplicate job

Job bisa masuk dua kali karena client retry, user klik berulang, timeout jaringan, atau worker crash setelah efek samping sudah terjadi tetapi sebelum status job diperbarui.

Solusinya bukan hanya “jangan enqueue dua kali”, melainkan juga idempotensi saat eksekusi. Bahkan jika job ganda lolos ke queue, hasil akhirnya harus tetap benar.

2. Race condition

Dua worker dapat mengambil job yang sama atau job berbeda yang memodifikasi resource yang sama. Jika tidak ada mekanisme lock atau desain state yang aman, hasilnya bisa inkonsisten.

3. Worker crash

Worker bisa mati di tengah proses: proses dibunuh, deploy baru, OOM, koneksi database putus, atau host restart. Sistem queue harus bisa memulihkan job yang “sedang dikerjakan” tetapi belum selesai.

4. Visibility timeout

Saat worker mengambil job, job perlu disembunyikan sementara dari worker lain. Jika worker gagal menyelesaikannya dalam batas waktu tertentu, job harus kembali terlihat agar bisa diproses ulang.

5. Cache stale dan thundering herd

Begitu data utama berubah, cache bisa menjadi usang. Jika invalidasi tidak dirancang, pembaca mendapat data lama. Jika banyak request serentak membangun ulang cache yang sama, Anda terkena thundering herd.

Arsitektur sederhana yang biasanya cukup

Arsitektur minimal yang praktis:

  • API service Deno: menerima request, menyimpan perubahan utama, lalu enqueue job.
  • Queue storage: Postgres atau Redis.
  • Worker Deno: polling atau blocking pop, mengambil job, lock, proses, retry jika perlu.
  • Cache: Redis atau cache internal sesuai kebutuhan.
  • Observability: log terstruktur, metrik, dan alert dasar.

Skema alur dasar

  1. API menulis state utama ke database dalam transaksi.
  2. Dalam transaksi yang sama, API menulis record job ke tabel queue atau menulis ke outbox.
  3. Worker mengambil job yang siap diproses.
  4. Worker menandai job sebagai processing dengan lock dan visibility timeout.
  5. Worker menjalankan handler idempoten.
  6. Jika sukses, job ditandai done dan cache terkait di-invalidasi atau di-refresh.
  7. Jika gagal sementara, job dijadwalkan ulang dengan backoff.
  8. Jika gagal permanen atau melebihi batas retry, job masuk dead-letter.

Desain ini sengaja konservatif: lebih sedikit komponen, tetapi cukup kuat untuk sebagian besar beban kerja aplikasi bisnis.

Memilih backend queue: Postgres atau Redis?

Pakai Postgres jika

  • Anda sudah memakai Postgres sebagai sumber kebenaran utama.
  • Jumlah job belum ekstrem.
  • Anda butuh transaksi kuat antara perubahan data dan enqueue job.
  • Anda ingin mengurangi komponen operasional.

Keuntungan utama Postgres adalah konsistensi. Anda bisa menyimpan state bisnis dan job dalam transaksi yang sama, sehingga kecil kemungkinan data utama berhasil tersimpan tetapi job tidak pernah dibuat.

Pakai Redis jika

  • Anda butuh latensi lebih rendah untuk antrian dan cache.
  • Pola konsumsi job lebih intensif.
  • Anda menerima model konsistensi yang lebih longgar dibanding transaksi database utama.

Redis cocok untuk queue ringan dan cache, tetapi Anda harus lebih hati-hati pada relasi antara write ke database utama dan enqueue ke Redis. Jika dua langkah itu tidak atomik, Anda berisiko kehilangan sinkronisasi.

Kapan tidak perlu message broker besar?

Anda belum perlu broker besar jika:

  • Topologi konsumen masih sederhana.
  • Belum ada kebutuhan routing kompleks, fan-out masif, atau streaming jangka panjang.
  • Tim operasional kecil dan lebih terbantu oleh sistem yang mudah dipahami daripada fitur yang belum terpakai.

Broker besar layak dipertimbangkan bila kebutuhan Anda sudah bergeser ke throughput tinggi lintas banyak layanan, ordering yang lebih ketat, replay stream, atau pola eventing kompleks.

Desain tabel queue yang realistis

Jika memakai Postgres, tabel queue bisa sesederhana ini:

CREATE TABLE jobs (
  id BIGSERIAL PRIMARY KEY,
  type TEXT NOT NULL,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'queued',
  attempts INTEGER NOT NULL DEFAULT 0,
  max_attempts INTEGER NOT NULL DEFAULT 5,
  run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  locked_until TIMESTAMPTZ,
  locked_by TEXT,
  dedupe_key TEXT,
  last_error TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  finished_at TIMESTAMPTZ
);

CREATE INDEX idx_jobs_fetch
  ON jobs (status, run_at, locked_until);

CREATE UNIQUE INDEX idx_jobs_dedupe_key
  ON jobs (dedupe_key)
  WHERE dedupe_key IS NOT NULL AND status IN ('queued', 'processing');

Beberapa catatan penting:

  • type memudahkan satu queue melayani banyak jenis job.
  • run_at berguna untuk penjadwalan retry.
  • locked_until adalah inti visibility timeout.
  • dedupe_key membantu menahan enqueue ganda untuk kasus yang memang harus unik.
  • attempts dan max_attempts menentukan kapan job masuk dead-letter.

Jangan mengandalkan status saja tanpa locked_until. Jika worker mati saat status sudah berubah menjadi processing, job bisa terjebak selamanya kecuali ada timeout yang membuatnya bisa diambil ulang.

Locking yang aman tanpa terlalu rumit

Tujuan lock

Lock mencegah dua worker mengerjakan job yang sama pada saat bersamaan. Pada queue berbasis database, pola yang umum adalah mengambil job dalam transaksi, lalu menandainya sebagai sedang diproses dengan batas waktu lock.

Contoh alur pengambilan job

Pseudocode berikut menunjukkan idenya, bukan bergantung pada library tertentu:

// ambil satu job yang siap diproses dan belum terkunci efektif
BEGIN;

SELECT id, type, payload, attempts, max_attempts
FROM jobs
WHERE status IN ('queued', 'processing')
  AND run_at <= NOW()
  AND (locked_until IS NULL OR locked_until < NOW())
ORDER BY run_at ASC, id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;

-- jika ada row:
UPDATE jobs
SET status = 'processing',
    attempts = attempts + 1,
    locked_until = NOW() + INTERVAL '60 seconds',
    locked_by = $workerId,
    updated_at = NOW()
WHERE id = $jobId;

COMMIT;

Pola ini bekerja karena:

  • FOR UPDATE SKIP LOCKED mencegah worker lain menunggu row yang sedang diambil worker pertama.
  • locked_until memastikan job kembali tersedia jika worker crash.
  • attempts dinaikkan saat mulai diproses agar retry terhitung walau worker mati di tengah jalan.

Visibility timeout jangan terlalu pendek atau terlalu panjang

Jika terlalu pendek, job masih berjalan tetapi lock habis sehingga worker lain mengambil ulang. Jika terlalu panjang, pemulihan dari crash menjadi lambat. Pilih timeout berdasarkan durasi job normal, lalu sediakan mekanisme heartbeat atau perpanjangan lock untuk job yang lebih lama.

Contoh pendekatan:

  • Job kecil: timeout tetap 30-60 detik.
  • Job lebih lama: worker memperbarui locked_until secara berkala selama masih sehat.

Idempotensi: pertahanan utama terhadap duplicate job

Lock mencegah eksekusi paralel yang tidak diinginkan, tetapi tidak cukup. Dalam sistem terdistribusi, setidaknya sekali adalah kenyataan yang lebih realistis daripada tepat sekali. Karena itu, handler job harus idempoten.

Cara menerapkan idempotensi

  • Gunakan idempotency key untuk operasi eksternal seperti pembayaran, webhook, atau email dengan provider yang mendukungnya.
  • Simpan status efek samping di database sebelum atau sesudah call eksternal dengan aturan yang bisa dideteksi ulang.
  • Gunakan unique constraint untuk mencegah insert hasil yang sama dua kali.
  • Desain handler agar aman dipanggil ulang: jika hasil sudah ada, cukup anggap sukses.

Contoh pola handler

type Job = {
  id: number;
  type: "refresh_user_cache";
  payload: { userId: string; version: number };
};

async function handleRefreshUserCache(job: Job, deps: {
  db: any;
  cache: any;
}) {
  const { userId, version } = job.payload;

  const row = await deps.db.queryObject(
    "SELECT cache_version FROM users WHERE id = $1",
    [userId],
  );

  const currentVersion = row.rows[0]?.cache_version;
  if (currentVersion == null) {
    return;
  }

  // Jika job lama datang belakangan, abaikan agar tidak menulis cache stale.
  if (version < currentVersion) {
    return;
  }

  const freshUser = await deps.db.queryObject(
    "SELECT id, name, email, cache_version FROM users WHERE id = $1",
    [userId],
  );

  await deps.cache.set(
    `user:${userId}`,
    JSON.stringify(freshUser.rows[0]),
    { ex: 300 },
  );
}

Kuncinya bukan pada Deno semata, melainkan pada desain data: job membawa version sehingga job lama tidak menimpa cache dengan data usang.

Retry, backoff, dan dead-letter queue

Bedakan error sementara dan error permanen

Tidak semua kegagalan layak di-retry.

  • Sementara: timeout jaringan, rate limit, koneksi database putus, layanan downstream 503.
  • Permanen: payload tidak valid, resource sudah dihapus, bug logika yang membuat job pasti gagal.

Jika semua error di-retry tanpa klasifikasi, queue akan dipenuhi job yang tidak mungkin berhasil.

Backoff sederhana yang cukup aman

Gunakan exponential backoff dengan sedikit jitter agar banyak job gagal tidak mencoba lagi di saat yang sama.

function nextDelaySeconds(attempt: number): number {
  const base = Math.min(300, 2 ** attempt);
  const jitter = Math.floor(Math.random() * 5);
  return base + jitter;
}

Saat gagal sementara:

  • status kembali ke queued,
  • run_at di-set ke sekarang + delay,
  • locked_until dikosongkan.

Saat melebihi batas retry atau error permanen:

  • pindahkan ke status dead_letter, atau
  • simpan ke tabel terpisah untuk investigasi.

Mengapa dead-letter penting?

Dead-letter bukan sekadar tempat membuang job gagal. Fungsinya untuk:

  • menghentikan loop retry tak berujung,
  • memberi visibilitas pada error yang butuh tindakan manual,
  • menyediakan bahan replay setelah bug diperbaiki.

Cache invalidation dan eventual consistency tanpa drama

Queue worker sering dipakai untuk menjaga cache tetap dekat dengan sumber data. Tetapi cache invalidation mudah salah jika tidak jelas model konsistensinya.

Pilih salah satu strategi utama

  • Invalidate on write: saat data berubah, hapus cache. Request berikutnya membangun ulang.
  • Write-through / refresh async: saat data berubah, enqueue job untuk mengisi cache baru.
  • Versioned cache: simpan versi data agar penulisan lama tidak menimpa hasil baru.

Untuk banyak aplikasi, kombinasi invalidate + async refresh paling praktis. Data utama tetap benar di database, cache boleh beberapa saat tertinggal, tetapi segera dipulihkan oleh worker.

Mencegah cache stale

Masalah umum: job refresh yang lebih lama selesai setelah job yang lebih baru, lalu menulis cache lama. Solusinya:

  • simpan version atau updated_at di payload job,
  • saat worker hendak menulis cache, bandingkan dengan versi terbaru di database,
  • abaikan job yang versinya lebih lama.

Mencegah thundering herd

Saat cache dihapus, banyak request bisa serentak mengisi ulang key yang sama. Beberapa pendekatan:

  • Single-flight lock per key di Redis atau memory lokal bila satu instance.
  • Soft TTL: sajikan cache lama sebentar sambil refresh berjalan di background.
  • Jitter pada TTL: hindari banyak key kedaluwarsa pada waktu yang sama.

Catatan: jika Anda punya banyak instance API, lock di memory proses tidak cukup untuk melindungi key bersama. Gunakan lock terdistribusi yang sederhana, atau desain cache agar aman terhadap rebuild paralel.

Outbox pattern saat database dan queue berbeda

Jika data utama ada di Postgres tetapi queue ada di Redis, ada celah: write ke database sukses, lalu aplikasi crash sebelum enqueue ke Redis. Akibatnya state berubah, tetapi job tidak pernah jalan.

Untuk mengurangi risiko ini, pertimbangkan outbox pattern:

  1. Dalam transaksi database yang sama dengan perubahan bisnis, tulis event ke tabel outbox.
  2. Proses terpisah membaca outbox dan mengirimkannya ke queue atau langsung menjalankan worker logic.
  3. Setelah berhasil dikirim, tandai outbox sebagai diproses.

Pola ini menambah satu langkah, tetapi menjaga konsistensi lebih baik ketika storage utama dan queue berbeda.

Contoh loop worker sederhana di Deno

Contoh berikut sengaja generik agar fokus pada alur, bukan pada driver tertentu.

async function workerLoop(deps: {
  fetchNextJob: (workerId: string) => Promise<any | null>;
  markDone: (jobId: number) => Promise<void>;
  markRetry: (jobId: number, error: string, delaySeconds: number) => Promise<void>;
  markDeadLetter: (jobId: number, error: string) => Promise<void>;
  heartbeat: (jobId: number, workerId: string) => Promise<void>;
}) {
  const workerId = crypto.randomUUID();

  while (true) {
    const job = await deps.fetchNextJob(workerId);
    if (!job) {
      await new Promise((r) => setTimeout(r, 1000));
      continue;
    }

    const timer = setInterval(() => {
      deps.heartbeat(job.id, workerId).catch(() => {});
    }, 15000);

    try {
      await dispatch(job);
      await deps.markDone(job.id);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      if (isPermanentError(err) || job.attempts >= job.max_attempts) {
        await deps.markDeadLetter(job.id, message);
      } else {
        await deps.markRetry(job.id, message, nextDelaySeconds(job.attempts));
      }
    } finally {
      clearInterval(timer);
    }
  }
}

Yang penting dari loop ini:

  • heartbeat memperpanjang visibility timeout untuk job yang berjalan lama,
  • dispatch harus idempoten,
  • retry dan dead-letter diputuskan berdasarkan jenis error dan jumlah attempt.

Common mistakes yang sering terjadi

1. Menganggap lock berarti exactly-once

Tidak. Lock hanya mengurangi konkurensi pada job yang sama. Crash setelah efek samping tetap bisa menyebabkan eksekusi ulang.

2. Retry semua error

Ini membuat queue penuh oleh job rusak permanen. Klasifikasikan error sedini mungkin.

3. Tidak punya timeout pada handler

Request ke layanan eksternal tanpa timeout membuat job menggantung. Akibatnya lock bertahan terlalu lama atau worker menumpuk.

4. Menghapus cache tanpa strategi rebuild

Invalidate cache itu mudah. Menjaga agar pembacaan setelah invalidasi tidak memicu herd justru bagian sulitnya.

5. Tidak menyimpan konteks error

Jika hanya menyimpan “failed”, Anda akan kesulitan investigasi. Simpan pesan error, attempt terakhir, worker id, dan timestamp.

Checklist observability untuk queue worker

Minimal, siapkan hal berikut:

Log terstruktur

  • job_id
  • job_type
  • worker_id
  • attempt
  • duration_ms
  • result: success / retry / dead_letter
  • error_message

Metrik penting

  • jumlah job queued, processing, done, dead-letter
  • retry rate per jenis job
  • processing duration per handler
  • queue lag: selisih waktu antara created_at/run_at dan mulai diproses
  • stale lock count: job processing dengan locked_until yang sudah lewat

Alert dasar

  • dead-letter naik tajam
  • queue lag melebihi ambang
  • worker tidak mengambil job dalam periode tertentu
  • banyak lock kadaluarsa menandakan crash atau timeout salah

Tanpa observability, masalah queue biasanya baru terlihat dari keluhan pengguna: email tidak terkirim, cache tidak update, atau sinkronisasi tertinggal berjam-jam.

Deployment aman untuk worker Deno

Graceful shutdown

Saat deploy atau scaling down, worker harus berhenti menerima job baru, menyelesaikan job yang sedang berjalan jika memungkinkan, lalu keluar dengan bersih. Jika tidak, banyak job akan kembali ke retry karena lock habis.

Batasi concurrency

Jangan memproses terlalu banyak job paralel hanya karena runtime mampu. Concurrency harus disesuaikan dengan:

  • kapasitas database,
  • batas rate layanan downstream,
  • memori per job,
  • dampak pada cache dan koneksi jaringan.

Gunakan timeout dan cancellation

Setiap operasi I/O penting sebaiknya punya timeout. Untuk job lama, pertimbangkan pola pembatalan yang jelas agar shutdown tidak menggantung.

Jalankan lebih dari satu worker, tetapi tetap sederhana

Skala horizontal boleh, asalkan lock dan idempotensi benar. Menambah instance worker tanpa dua hal itu hanya memperbesar race condition.

Kapan desain sederhana ini mulai tidak cukup?

Anda mungkin perlu naik kelas jika menemui kondisi seperti:

  • throughput tinggi yang membuat polling database menjadi mahal,
  • banyak konsumen lintas layanan dengan pola routing kompleks,
  • kebutuhan ordering ketat di banyak partisi,
  • replay event historis sebagai fitur utama,
  • retensi queue jangka panjang sebagai audit stream.

Namun sebelum sampai ke sana, banyak tim justru diuntungkan oleh sistem yang lebih kecil, jelas, dan bisa dipahami penuh oleh developer backend sehari-hari.

Penutup

Deno Queue Worker yang baik tidak harus dibangun dengan over-engineering. Untuk sebagian besar job background, kombinasi lock yang benar, handler idempoten, retry dengan backoff, dead-letter, visibility timeout, dan strategi cache yang sadar eventual consistency sudah cukup memberi sistem yang stabil.

Mulailah dari kebutuhan operasional nyata: bagaimana job diambil, apa yang terjadi saat crash, bagaimana duplicate dihadapi, kapan cache dihapus atau di-refresh, dan bagaimana Anda tahu sistem sedang sehat. Jika jawaban untuk pertanyaan-pertanyaan itu jelas, Anda sudah punya fondasi queue worker yang jauh lebih berguna daripada sekadar menambahkan teknologi baru.