Pada lingkungan dengan jaringan labil, masalah utamanya bukan sekadar request timeout, tetapi bagaimana memastikan pekerjaan tetap tercatat, dapat diulang dengan aman, dan tidak merusak konsistensi data saat koneksi kembali normal. Desain worker offline-first biasanya menggabungkan dua komponen inti: local queue untuk menahan operasi yang belum bisa dikirim, dan cache lokal untuk menjaga aplikasi tetap responsif saat offline.

Artikel ini membahas pola praktis untuk merancang queue worker dan cache pada perangkat yang sering berpindah jaringan atau terputus, terinspirasi dari konteks Linux mobile/offline-first seperti perangkat yang menjalankan postmarketOS, tetapi fokus pembahasannya ada pada backend dan sistem terdistribusi. Tujuannya bukan mengejar exactly-once yang sulit dan mahal, melainkan membangun sistem yang tahan gagal, mudah diobservasi, dan cukup sederhana untuk dioperasikan.

Mengapa pendekatan offline-first berbeda dari queue biasa

Queue pada server yang selalu tersambung umumnya mengasumsikan broker stabil, jam sistem relatif konsisten, dan worker dapat mengakses jaringan kapan saja. Pada lingkungan offline-first, asumsi itu tidak berlaku. Perangkat bisa:

  • berpindah dari Wi-Fi ke seluler lalu offline total,
  • kehabisan baterai di tengah proses sinkronisasi,
  • menjalankan ulang proses worker saat ada update sistem,
  • menyimpan state lokal lebih lama daripada perkiraan awal.

Karena itu, desain yang aman biasanya memakai prinsip berikut:

  • Tulis dulu ke penyimpanan lokal yang durabel, baru kirim ke jaringan.
  • Anggap pengiriman bisa terjadi lebih dari sekali.
  • Jangan jadikan cache sebagai sumber kebenaran utama untuk operasi mutasi.
  • Pisahkan status lokal dan status sinkronisasi.
  • Sediakan mekanisme rekonsiliasi saat reconnect, bukan hanya retry membabi buta.

Arsitektur alur worker offline-first

Alur dasar yang umum dipakai terlihat seperti ini:

  1. Aplikasi menerima aksi pengguna atau event lokal.
  2. Operasi ditulis ke local queue dalam storage durabel, misalnya SQLite.
  3. Data tampilan diperbarui ke cache lokal agar UI atau service tetap responsif.
  4. Worker lokal memantau queue dan mencoba mengirim job saat jaringan tersedia.
  5. Server memproses job menggunakan idempotency key.
  6. Saat berhasil, item queue ditandai selesai dan cache diperbarui bila perlu.
  7. Saat gagal, worker menghitung retry schedule dengan backoff dan menyimpan alasan gagal.
  8. Saat reconnect setelah lama offline, sistem menjalankan revalidation, cache invalidation, dan bila perlu conflict resolution.

Komponen minimal

  • Persistent local queue: menyimpan job, payload, metadata retry, dan status sinkronisasi.
  • Connectivity detector: sinyal bahwa jaringan tersedia, tetapi jangan menganggapnya sebagai jaminan request pasti berhasil.
  • Delivery worker: menarik job yang siap dikirim.
  • Idempotent server endpoint: mencegah efek ganda dari retry.
  • Cache store: menyimpan snapshot data yang bisa dipakai saat offline.
  • Reconciliation logic: membandingkan state lokal dan state server setelah reconnect.

Merancang local queue yang aman

Untuk perangkat atau node kecil dengan kebutuhan sederhana hingga menengah, SQLite sering cukup sebagai queue lokal karena durabel, ringan, dan tidak membutuhkan service tambahan. Kuncinya ada pada skema yang jelas dan transisi status yang disiplin.

Skema tabel queue yang praktis

CREATE TABLE jobs (
  id TEXT PRIMARY KEY,
  topic TEXT NOT NULL,
  payload TEXT NOT NULL,
  idempotency_key TEXT NOT NULL,
  status TEXT NOT NULL,          -- pending, leased, done, failed, dead
  attempt_count INTEGER NOT NULL DEFAULT 0,
  next_attempt_at TEXT NOT NULL,
  last_error TEXT,
  locked_by TEXT,
  locked_until TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

CREATE INDEX idx_jobs_ready
  ON jobs(status, next_attempt_at);

CREATE UNIQUE INDEX idx_jobs_idempotency
  ON jobs(idempotency_key);

Beberapa catatan desain:

  • id adalah identitas lokal job.
  • idempotency_key dipakai saat mengirim ke server agar retry tetap aman.
  • status leased berguna untuk mencegah dua worker lokal memproses item yang sama bersamaan.
  • locked_until mencegah lease hilang permanen bila proses mati mendadak.

Pola lease lebih aman daripada delete langsung

Kesalahan umum adalah mengambil satu job lalu langsung menghapusnya dari queue sebelum server mengonfirmasi sukses. Jika proses mati setelah job dihapus tetapi sebelum respons sukses dicatat, job hilang. Pola yang lebih aman:

  1. Pilih job yang siap diproses.
  2. Ubah status menjadi leased dengan batas waktu lease.
  3. Kirim ke server.
  4. Jika sukses, tandai done.
  5. Jika gagal, kembalikan ke pending dengan next_attempt_at baru.

Dengan begitu, crash di tengah proses tidak langsung menghilangkan pekerjaan.

Contoh pseudocode worker

loop:
  if !networkLikelyAvailable():
    sleep(short_interval)
    continue

  begin transaction
    job = select one pending job
          where next_attempt_at <= now()
          order by created_at asc
    if no job:
      commit
      sleep(short_interval)
      continue

    mark job as leased
      locked_by = worker_id
      locked_until = now() + lease_duration
      updated_at = now()
  commit

  result = sendToServer(job.payload, job.idempotency_key)

  begin transaction
    if result.success:
      mark job done
    else if result.retryable:
      increment attempt_count
      set status = pending
      set next_attempt_at = now() + backoff(attempt_count)
      set last_error = result.error
    else:
      mark job dead
      set last_error = result.error
  commit

Yang penting di sini bukan sintaksnya, tetapi dua hal: claim job secara atomik dan simpan hasil proses kembali ke storage durabel.

Retry dengan backoff: jangan kirim ulang secara agresif

Pada jaringan labil, retry yang terlalu cepat justru memperparah masalah: baterai boros, radio jaringan aktif terus, server kewalahan, dan antrean lokal makin menumpuk. Karena itu gunakan exponential backoff dengan jitter.

Prinsip backoff yang masuk akal

  • Kegagalan sementara seperti timeout, DNS gagal, atau HTTP 502/503/504 biasanya retryable.
  • Kegagalan permanen seperti payload invalid atau autentikasi salah biasanya tidak boleh di-retry tanpa perubahan state.
  • Tambahkan jitter agar banyak worker tidak menembak ulang bersamaan saat jaringan pulih.
  • Tetapkan batas maksimum percobaan atau kirim job ke status dead untuk inspeksi manual.

Contoh fungsi backoff

function backoffSeconds(attempt) {
  const base = 2;
  const cap = 300; // 5 menit sebagai batas atas contoh
  const exp = Math.min(cap, Math.pow(base, attempt));
  const jitter = Math.random() * Math.min(30, exp * 0.2);
  return exp + jitter;
}

Nilai pastinya harus disesuaikan dengan beban, karakter jaringan, dan SLA. Yang lebih penting adalah pola umumnya: interval retry membesar, dibatasi, dan tidak seragam.

Catatan: jangan hanya bergantung pada sinyal "online" dari sistem operasi. Koneksi bisa terdeteksi aktif, tetapi DNS, TLS handshake, atau akses ke endpoint inti tetap gagal. Worker tetap perlu menangani error jaringan nyata di lapisan aplikasi.

Idempotency key: fondasi utama at-least-once

Pada sistem offline-first, at-least-once delivery adalah model yang paling realistis: job mungkin terkirim lebih dari sekali, tetapi seharusnya efek bisnisnya tidak terduplikasi. Di sinilah idempotency key sangat penting.

Cara kerjanya

Setiap operasi mutasi diberi kunci unik yang stabil untuk satu niat bisnis. Misalnya, "buat pesanan X" atau "upload hasil sinkronisasi sensor Y batch Z". Saat request datang ulang dengan idempotency key yang sama, server harus mengembalikan hasil yang konsisten atau menolak duplikasi tanpa mengeksekusi efek samping lagi.

Kapan idempotency key harus dibuat

  • Buat saat job ditulis ke local queue, bukan saat akan dikirim.
  • Simpan bersama payload.
  • Pastikan key tetap sama di seluruh retry.

Contoh bentuk request

POST /sync/events
Idempotency-Key: 8f5f1d95-1e8b-4b51-9f53-2c6bb7d2e4b1
Content-Type: application/json

{
  "device_id": "edge-17",
  "event_id": "evt-00123",
  "occurred_at": "2026-07-20T10:15:00Z",
  "payload": {
    "status": "completed"
  }
}

Di sisi server

Server biasanya menyimpan catatan berdasarkan idempotency key:

  • apakah key pernah diproses,
  • hasil respons sebelumnya,
  • status apakah masih diproses atau sudah selesai.

Implementasi detail bisa memakai tabel database dengan unique constraint pada idempotency key. Jika dua request identik datang hampir bersamaan, constraint database lebih andal daripada logika cek-terus-insert yang rawan race condition.

Kesalahan umum

  • Menggunakan timestamp sebagai idempotency key tanpa identitas bisnis yang stabil.
  • Membuat key baru setiap retry.
  • Menganggap idempotensi cukup ditangani di client, padahal validasi akhirnya harus dilakukan di server.

Distributed locking vs local file lock

Pertanyaan yang sering muncul: apakah worker perlu distributed lock seperti Redis lock, atau cukup file lock lokal? Jawabannya tergantung cakupan kontensi.

Kapan local file lock cukup

Jika satu perangkat atau satu host hanya boleh menjalankan satu instance worker untuk queue lokal yang sama, maka file lock lokal biasanya cukup. Contohnya memakai file lock untuk memastikan hanya ada satu proses yang menarik job dari SQLite di perangkat tersebut.

Kelebihan:

  • sederhana,
  • tanpa dependency jaringan tambahan,
  • tetap bekerja saat offline penuh.

Kekurangan:

  • hanya berlaku pada satu host,
  • tidak membantu jika beberapa node berbagi antrean yang sama.

Kapan distributed locking dibutuhkan

Jika banyak worker pada node berbeda memproses sumber kerja bersama, Anda butuh koordinasi antar node. Di sini distributed lock bisa dipakai, tetapi jangan jadikan lock sebagai satu-satunya mekanisme keselamatan. Lock bisa kedaluwarsa, jaringan bisa terbelah, dan jam antarmesin bisa tidak sinkron sempurna.

Pada banyak kasus, desain yang lebih kokoh adalah:

  • gunakan lease di level data pada database atau broker,
  • andalkan unique constraint dan idempotensi,
  • perlakukan lock hanya sebagai optimasi pengurang kontensi, bukan penjamin kebenaran mutlak.

Panduan memilih

  • Single device / single host → local file lock atau lease di SQLite cukup.
  • Multi-process satu host → file lock + transaksi database lokal.
  • Multi-host shared queue → lease di database/broker, optional distributed lock bila memang perlu.

Desain cache untuk offline-first dan invalidasi saat reconnect

Cache pada desain offline-first bukan hanya untuk mempercepat baca, tetapi juga agar aplikasi tetap usable saat backend tidak dapat dijangkau. Namun cache lokal harus dianggap sebagai snapshot yang mungkin usang.

Pola cache yang umum

  • Read-through cache: baca dari cache dulu, lalu fallback ke jaringan.
  • Write-behind dengan queue: mutasi dicatat lokal dulu lalu disinkronkan belakangan.
  • Stale-while-revalidate: tampilkan data cache, lalu segarkan di belakang saat jaringan tersedia.

Apa yang harus disimpan bersama cache

  • versi atau etag bila ada,
  • waktu terakhir sinkronisasi,
  • asal data apakah lokal belum tersinkron atau dari server,
  • dependency key untuk invalidasi terarah.

Cache invalidation saat reconnect

Saat perangkat kembali online, jangan langsung mengosongkan seluruh cache. Itu membuat lonjakan request dan pengalaman buruk. Lebih aman memakai langkah berikut:

  1. Kirim job tertunda yang bersifat mutasi.
  2. Ambil perubahan terbaru dari server berdasarkan token sinkronisasi, versi, atau rentang waktu yang aman.
  3. Invalidasi hanya key cache yang terdampak oleh mutasi lokal atau perubahan server.
  4. Lakukan revalidation untuk entitas prioritas tinggi terlebih dahulu.

Jika server mendukung ETag atau last modified, manfaatkan itu untuk mengurangi transfer data penuh. Jika tidak, minimal simpan penanda versi aplikasi atau domain data agar Anda tahu kapan cache harus dianggap tidak valid.

Kesalahan umum pada cache offline-first

  • Menyamakan cache dengan sumber kebenaran final.
  • Tidak menandai data yang masih pending sync.
  • Menghapus cache total saat reconnect sehingga seluruh state harus dimuat ulang.
  • Tidak punya strategi migrasi cache ketika skema payload berubah.

Conflict resolution setelah reconnect

Begitu perangkat offline cukup lama, konflik hampir pasti terjadi. Misalnya data yang sama diubah di server dan di perangkat lokal. Menyelesaikan konflik bukan tugas queue semata, melainkan aturan domain bisnis.

Pendekatan yang umum

  • Last write wins: sederhana, tetapi berisiko menimpa perubahan penting.
  • Version check / optimistic concurrency: update hanya diterima jika versi cocok; jika tidak, client harus rekonsiliasi.
  • Merge per field: cocok untuk data yang dapat digabung tanpa ambigu.
  • Server authoritative + manual review: cocok untuk domain sensitif seperti keuangan atau inventaris.

Contoh alur konflik yang sehat

  1. Client menyimpan edit lokal bersama base version dari data saat terakhir dibaca.
  2. Saat sinkronisasi, server membandingkan base version dengan versi terbaru.
  3. Jika cocok, update diterima.
  4. Jika tidak, server mengembalikan status konflik beserta state terbaru.
  5. Client menandai item sebagai perlu rekonsiliasi, bukan terus me-retry payload lama.

Retry tanpa memahami konflik hanya menghasilkan antrean yang tidak pernah selesai.

At-least-once vs exactly-once: pilih yang realistis

Pada praktik sistem terdistribusi, exactly-once end-to-end jarang benar-benar tercapai tanpa batasan yang ketat dan biaya kompleksitas yang tinggi. Yang lebih realistis adalah kombinasi:

  • at-least-once delivery di sisi transport atau worker,
  • idempotent processing di sisi aplikasi,
  • deduplication di database atau service penerima.

Trade-off utama

At-least-once

  • Kelebihan: lebih sederhana, tahan terhadap crash, cocok untuk jaringan labil.
  • Kekurangan: duplikasi harus ditangani dengan serius.

Exactly-once

  • Kelebihan: model mental lebih nyaman di atas kertas.
  • Kekurangan: sulit dibangun lintas jaringan, storage, dan side effect; sering bergantung pada asumsi sempit.

Jika job memicu side effect eksternal seperti email, pembayaran, atau webhook, anggap selalu ada kemungkinan proses ulang. Solusi terbaik biasanya ada pada desain bisnis yang idempotent dan dapat dideduplikasi.

Failure mode operasional yang sering terjadi

1. Job sukses di server, tetapi client mengira gagal

Ini klasik: request diterima dan diproses, tetapi respons hilang karena timeout. Jika client me-retry tanpa idempotency key, efek ganda bisa terjadi. Dengan idempotency key, retry aman karena server mengenali operasi yang sama.

2. Worker crash setelah lease, sebelum update status

Tanpa locked_until, job bisa macet selamanya di status leased. Karena itu lease harus punya kedaluwarsa dan ada proses yang melepaskan atau mengambil kembali lease usang.

3. Redis tersedia, tetapi jaringan ke API utama gagal

Jangan menganggap dependency yang sehat berarti seluruh sistem sehat. Observabilitas harus memisahkan status broker/cache dari status endpoint bisnis.

4. Reconnect memicu banjir sinkronisasi

Saat banyak perangkat kembali online bersamaan, semua worker bisa membanjiri API. Mitigasinya: backoff, jitter, pembatasan laju, dan prioritas antrean.

5. Cache usang menimpa data server

Terjadi jika mutasi lokal menggunakan snapshot lama tanpa version check. Solusinya: kirim versi dasar atau etag saat update, lalu tangani konflik secara eksplisit.

6. Jam sistem perangkat melenceng

Jika logika retry atau konflik terlalu bergantung pada timestamp lokal, hasilnya bisa aneh. Lebih aman memakai counter versi, lease berbasis durasi lokal yang konservatif, dan keputusan final di server.

Metrik observabilitas yang wajib dipantau

Worker offline-first sulit dioperasikan tanpa metrik yang tepat. Jangan hanya melihat error rate global.

Metrik queue

  • queue depth: jumlah job pending, leased, dead.
  • oldest pending age: umur job tertua yang belum tersinkron.
  • attempt count distribution: apakah banyak job berulang kali gagal.
  • lease timeout count: indikasi worker sering crash atau hang.

Metrik delivery

  • success rate per endpoint atau topic.
  • retry rate dan alasan retry.
  • latency pengiriman dan waktu total sampai selesai sinkron.
  • duplicate detection rate dari idempotency key di server.

Metrik cache dan sinkronisasi

  • cache hit ratio untuk baca offline/lokal.
  • staleness age data cache.
  • reconciliation duration setelah reconnect.
  • conflict count dan tipe konflik.

Metrik resource perangkat

  • ukuran database lokal,
  • pertumbuhan queue,
  • penggunaan disk, CPU, dan memori worker,
  • dampak retry terhadap konsumsi baterai atau radio jaringan bila relevan.

Untuk debugging, sertakan correlation ID yang menghubungkan log lokal, job queue, request ke server, dan respons server. Tanpa itu, investigasi duplikasi atau job hilang akan sangat sulit.

Kapan cukup dengan SQLite atau Redis, kapan perlu broker khusus

Cukup dengan SQLite jika

  • queue bersifat lokal per perangkat atau per host,
  • throughput relatif moderat,
  • butuh durabilitas sederhana tanpa service tambahan,
  • mode offline penuh harus tetap berjalan mandiri.

SQLite unggul untuk local queue karena dekat dengan aplikasi dan tahan restart. Ini sering pilihan terbaik untuk edge device, worker di perangkat mobile Linux, atau service pendamping yang berjalan mandiri.

Cukup dengan Redis jika

  • Anda butuh antrean cepat di lingkungan yang mayoritas online,
  • durabilitas tidak sepenuhnya lokal per perangkat,
  • beberapa worker perlu berbagi state atau koordinasi ringan,
  • Anda siap menerima trade-off persistence dan operasional Redis sesuai konfigurasi yang dipilih.

Redis cocok untuk banyak kasus backend, tetapi untuk offline-first murni di perangkat yang bisa benar-benar terputus, Redis bukan pengganti storage lokal durabel di perangkat itu sendiri.

Naik ke broker khusus jika

  • butuh throughput lebih tinggi dan konsumen banyak,
  • perlu fitur routing, fan-out, partitioning, atau retention yang lebih matang,
  • perlu pemisahan jelas antara producer dan consumer pada banyak sistem,
  • kegagalan dan observabilitas sudah terlalu kompleks untuk ditangani dengan queue buatan sendiri.

Namun broker khusus tidak otomatis menyelesaikan masalah offline-first. Anda tetap memerlukan local queue di sisi perangkat jika perangkat bisa terputus total dari broker.

Checklist keputusan praktis

  • Apakah node bisa offline total? Jika ya, harus ada persistent local queue.
  • Apakah job harus bertahan setelah reboot? Jika ya, gunakan storage durabel.
  • Apakah ada banyak konsumen lintas host? Jika ya, pertimbangkan broker atau database shared dengan lease yang baik.
  • Apakah duplikasi bisa diterima bila idempotent? Jika ya, at-least-once biasanya cukup.
  • Apakah konflik data perlu rekonsiliasi domain? Jika ya, desain conflict resolution lebih penting daripada mengganti broker.
  • Apakah beban operasional service tambahan sepadan? Jika tidak, mulai dari SQLite lalu evolusikan.

Praktik implementasi yang direkomendasikan

  • Simpan job ke local queue sebelum mengubah state sinkronisasi apa pun di memori.
  • Gunakan status yang eksplisit: pending, leased, done, dead.
  • Pisahkan error retryable dan non-retryable.
  • Bangun endpoint server yang idempotent by design.
  • Tandai entitas cache yang masih punya perubahan lokal belum tersinkron.
  • Saat reconnect, lakukan flush mutasi dulu, lalu revalidate cache.
  • Sediakan dead-letter handling walau sederhana, misalnya tabel khusus atau dashboard admin.
  • Uji skenario crash di tengah proses, bukan hanya jalur sukses.

Penutup

Desain worker offline-first untuk jaringan labil paling efektif jika dibangun di atas local queue yang durabel, retry dengan backoff dan jitter, idempotency key, serta cache yang sadar terhadap status sinkronisasi. Untuk banyak kasus edge device atau perangkat Linux mobile, kombinasi SQLite untuk queue lokal dan cache lokal yang bisa direvalidasi saat reconnect sudah cukup kuat sebelum Anda mempertimbangkan broker yang lebih berat.

Fokus utamanya bukan mengejar jaminan teoritis yang mahal, melainkan memastikan sistem tetap benar saat gagal: job tidak hilang, duplikasi tidak merusak data, konflik bisa ditangani, dan operator dapat melihat apa yang terjadi. Jika fondasi ini sudah ada, sistem Anda akan jauh lebih tahan terhadap kenyataan jaringan yang tidak stabil.