Android Doze Mode dan optimasi baterai agresif dari vendor (OEM) sering menghentikan proses React Native di latar belakang tanpa memicu event lifecycle standar. Ketika OS mengirimkan SIGKILL pada runtime Headless JS, eksekusi JavaScript berhenti seketika. Blok try...finally tidak dieksekusi, koneksi database lokal terputus, dan task yang sedang berjalan tertinggal dalam status pemrosesan tanpa pernah mengirim acknowledgement (ACK) atau status gagal.

Kondisi ini menyebabkan antrean lokal macet permanen (stalled queue). Tanpa mekanisme pemulihan yang tepat, item antrean tersebut dianggap masih berjalan sehingga tidak akan pernah dieksekusi ulang. Artikel ini membahas strategi mengatasi silent kill menggunakan pola job leasing, desain state machine yang tangguh, serta panduan memilih mekanisme native execution yang tepat.

Akar Masalah: Mengapa Headless JS Gagal Menangani Doze Mode

Headless JS bekerja dengan memicu HeadlessJsTaskService dari sisi Android native untuk memulai konteks React Native terpisah saat aplikasi di latar belakang. Namun, lingkungan ini memiliki batas toleransi yang sangat ketat:

  • Doze Mode: Saat layar mati dan perangkat tidak bergerak dalam kondisi tidak diisi daya, Android masuk ke mode deep sleep. Akses jaringan diputus, wake locks diabaikan, dan alokasi waktu CPU untuk proses non-sistem ditiadakan.
  • OEM Aggressive Killers: Sistem operasi turunan vendor (seperti MIUI, ColorOS, EMUI) kerap mengabaikan spesifikasi standar AOSP dan mematikan background service jika waktu eksekusi melewati ambang batas beberapa detik.
  • Ketiadaan Shutdown Hook: Runtime JavaScript V8/Hermes tidak menerima sinyal terminasi OS. Eksekusi terputus di tengah operasi I/O atau mutasi basis data lokal.

Desain State Machine pada Local Storage

Untuk mencegah task hilang atau macet selamanya, setiap job dalam antrean harus disimpan di penyimpanan lokal persisten (seperti SQLite via react-native-quick-sqlite atau op-sqlite) menggunakan state machine eksplisit:

  • PENDING: Task siap dieksekusi oleh worker.
  • RUNNING: Task sedang dikerjakan oleh worker aktif dengan batas waktu sewa (lease timeout).
  • COMPLETED: Task selesai dieksekusi dan diverifikasi (dapat dihapus secara berkala).
  • FAILED: Task gagal melampaui batas percobaan maksimal (max retries) atau mengalami unrecoverable error.

Kunci dari penanganan silent kill adalah melengkapi status RUNNING dengan timestamp lease_expires_at. Jika proses dimatikan di tengah jalan, kita tidak mengandalkan penanganan error runtime, melainkan mengandalkan validitas waktu sewa.

Pola Job Leasing dan Cold Boot Recovery

Pola job leasing membatasi durasi kepemilikan sebuah worker terhadap suatu job. Alih-alih menandai job sebagai 'selesai' atau 'gagal' saat terjadi crash, worker berikutnya atau proses startup aplikasi akan memeriksa integritas antrean.

Mekanisme Rekonsiliasi

  1. Startup / Cold Boot: Setiap kali runtime JavaScript dimulai (baik via normal app launch maupun Headless JS run), sistem menjalankan fase stale jobs recovery.
  2. Evaluasi Timeout: Sistem mencari job dengan status RUNNING yang memiliki nilai lease_expires_at < CURRENT_TIMESTAMP.
  3. Rollback atau Requeue: Jika batas attempt_count belum tercapai, status diubah kembali menjadi PENDING. Jika sudah melewati batas, alihkan ke FAILED agar tidak terjadi loop tanpa akhir (poison pill).

Arsitektur Native: WorkManager vs Foreground Service

Headless JS standar tidak dirancang untuk menjamin eksekusi ketika Doze Mode aktif. Anda harus mengawinkan antrean lokal JavaScript dengan lapisan native Android:

KriteriaAndroid WorkManagerForeground Service
Kasus PenggunaanSync data periodik, upload telemetri, deferred tasks yang toleran terhadap jeda.Tugas berdurasi panjang, pemrosesan real-time (navigasi, pemutaran audio, upload file besar).
Dampak Doze ModeMenghormati maintenance window Doze Mode secara otomatis; menjamin task tetap jalan saat window terbuka.Membypass Doze Mode sebagian dengan wake lock aktif, menjaga proses tetap prioritas tinggi.
Interaksi PenggunaBerjalan tanpa indikator UI (senyap).Wajib menampilkan Persistent Notification kepada pengguna.
Batas Waktu EksekusiMaksimal 10 menit berturut-turut per eksekusi.Tidak dibatasi ketat selama notifikasi aktif dan memori mencukupi.
Gunakan WorkManager untuk memicu Headless JS task jika pekerjaan tidak mendesak dan harus hemat daya. Gunakan Foreground Service hanya jika tugas tersebut merupakan interaksi langsung yang diketahui pengguna dan tidak boleh terhenti sama sekali.

Implementasi Minimalis Queue Worker & Recovery Lock

Berikut implementasi queue worker berbasis TypeScript yang idempotent, aman terhadap interupsi OS, dan mengadopsi mekanisme job leasing:

type JobStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED';

interface Job {
  id: string;
  idempotency_key: string;
  payload: string;
  status: JobStatus;
  attempt_count: number;
  max_retries: number;
  lease_expires_at: number | null;
}

// Simulasi abstraksi database SQLite
interface Database {
  execute(query: string, params?: any[]): Promise<any>;
  query<T>(query: string, params?: any[]): Promise<T[]>;
}

export class ResilientQueueWorker {
  private db: Database;
  private leaseDurationMs: number;

  constructor(db: Database, leaseDurationMs: number = 30000) {
    this.db = db;
    this.leaseDurationMs = leaseDurationMs;
  }

  // 1. Jalankan fungsi ini saat Cold Boot atau awal eksekusi Headless JS
  async recoverStaleJobs(): Promise<void> {
    const now = Date.now();
    
    // Kembalikan job yang mati mendadak ke PENDING jika attempt_count masih aman
    await this.db.execute(
      `UPDATE jobs 
       SET status = 'PENDING', lease_expires_at = NULL 
       WHERE status = 'RUNNING' 
         AND lease_expires_at < ? 
         AND attempt_count < max_retries`,
      [now]
    );

    // Tandai FAILED jika sudah melampaui batas retry
    await this.db.execute(
      `UPDATE jobs 
       SET status = 'FAILED', lease_expires_at = NULL 
       WHERE status = 'RUNNING' 
         AND lease_expires_at < ? 
         AND attempt_count >= max_retries`,
      [now]
    );
  }

  // 2. Akuisisi atomic menggunakan lease lock
  async acquireNextJob(): Promise<Job | null> {
    const now = Date.now();
    const leaseExpiry = now + this.leaseDurationMs;

    // Ambil 1 job PENDING tertua
    const pendingJobs = await this.db.query<Job>(
      `SELECT * FROM jobs 
       WHERE status = 'PENDING' 
       ORDER BY id ASC LIMIT 1`
    );

    if (pendingJobs.length === 0) return null;

    const targetJob = pendingJobs[0];

    // Lock job secara atomic
    const result = await this.db.execute(
      `UPDATE jobs 
       SET status = 'RUNNING', 
           attempt_count = attempt_count + 1, 
           lease_expires_at = ? 
       WHERE id = ? AND status = 'PENDING'`,
      [leaseExpiry, targetJob.id]
    );

    // Pastikan lock didapat tanpa race condition
    return result.rowsAffected > 0 ? targetJob : null;
  }

  // 3. Eksekusi tugas dengan jaminan status akhir
  async processJob(
    job: Job, 
    handler: (payload: any) => Promise<void>
  ): Promise<void> {
    try {
      const parsedPayload = JSON.parse(job.payload);
      
      // Handler wajib idempotent menggunakan idempotency_key
      await handler(parsedPayload);

      await this.db.execute(
        `UPDATE jobs 
         SET status = 'COMPLETED', lease_expires_at = NULL 
         WHERE id = ?`,
        [job.id]
      );
    } catch (error) {
      // Tangani error eksplisit (bukan silent kill)
      await this.db.execute(
        `UPDATE jobs 
         SET status = CASE 
           WHEN attempt_count >= max_retries THEN 'FAILED' 
           ELSE 'PENDING' 
         END,
         lease_expires_at = NULL 
         WHERE id = ?`,
        [job.id]
      );
    }
  }
}

Aturan Mutlak: Idempotensi Backend

Mekanisme recovery pada klien tidak lengkap tanpa penanganan idempotensi di sisi server. Pertimbangkan skenario berikut:

  1. Worker mengirim request mutasi (misalnya: POST /api/v1/orders).
  2. Server menerima request, memproses transaksi ke database, dan berhasil mengirim respons HTTP 200.
  3. Tepat saat paket respons tiba di perangkat, Android mematikan proses JS (silent kill) sebelum worker sempat mengeksekusi UPDATE jobs SET status = 'COMPLETED'.
  4. Ketika aplikasi dibuka kembali, mekanisme recovery mengembalikan job ke status PENDING. Job dieksekusi ulang.

Untuk menghindari transaksi ganda, setiap job wajib menyertakan idempotency_key unik (UUID v4) di payload dan header request. Server backend harus menyimpan key ini dalam cache/database transaksional untuk menolak atau mengabaikan payload yang telah sukses diproses sebelumnya.