Aplikasi React Native dengan fitur offline-first mengandalkan antrean lokal untuk menampung mutasi data saat perangkat kehilangan koneksi. Masalah kritis muncul ketika satu task berisi payload rusak atau menghasilkan penolakan permanen dari server (HTTP 400/422). Task jenis ini disebut poison pill.
Tanpa mekanisme isolasi, worker antrean offline akan mengeksekusi task rusak tersebut secara berulang (infinite retry loop). Dampaknya: eksekusi mutasi valid di belakangnya terblokir (head-of-line blocking), daya baterai terkuras cepat, penggunaan kuota data sia-sia, dan risiko aplikasi mengalami crash operasional.
Solusi standar arsitektur antrean adalah Dead Letter Queue (DLQ). Pola ini memutus task gagal permanen dari siklus pemrosesan utama dan memindahkannya ke storage isolasi untuk audit atau recovery manual.
1. Desain State Machine Antrean Offline
Penyimpanan lokal (baik menggunakan SQLite seperti op-sqlite atau key-value storage terenkripsi) membutuhkan skema status deterministik. Hindari status boolean sederhana seperti isSynced.
Gunakan state machine berikut:
- pending: Task baru masuk, siap diproses worker saat jaringan aktif.
- processing: Task sedang dikirim ke backend. Status ini mencegah eksekusi ganda jika worker terpicu ulang oleh network listener.
- failed: Task gagal akibat kendala transien (timeout, HTTP 503). Task dijadwalkan ulang menggunakan timestamp
next_retry_at. - dlq: Task melebihi batas percobaan (max retries) atau menerima error non-transient. Task diisolasi permanen dari worker otomatis.
interface QueueTask<T = unknown> {
id: string;
type: string;
payload: T;
status: 'pending' | 'processing' | 'failed' | 'dlq';
retryCount: number;
maxRetries: number;
nextRetryAt: number;
lastError?: string;
createdAt: number;
}2. Logika Retry: Exponential Backoff dan Jitter
Pengulangan instan (immediate retry) saat jaringan buruk memperparah beban server dan menguras baterai perangkat bergerak. Terapkan algoritma Exponential Backoff yang dipadukan dengan Full Jitter untuk menghindari sinkronisasi serentak (thundering herd problem).
Formula perhitungan penundaan:
function calculateBackoff(retryCount: number, baseDelayMs = 1000, maxDelayMs = 30000): number {
const exponential = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, retryCount));
// Full jitter: nilai acak antara 0 hingga delay eksponensial
return Math.floor(Math.random() * exponential);
}Klasifikasikan respons kegagalan sebelum memutuskan status berikutnya:
- Transient Errors (Network disconnect, DNS timeout, HTTP 500, 502, 503, 504): Naikkan
retryCount, set statusfailed, dan hitungnextRetryAtbaru. - Permanent Errors / Poison Pills (HTTP 400 Bad Request, 401 Unauthorized yang persisten, 422 Unprocessable Entity, payload JSON korup): Langsung transisikan ke status
dlqtanpa menghabiskan kuota retry.
3. Implementasi Queue Worker dan Isolasi DLQ
Berikut implementasi minimal queue manager TypeScript tanpa pustaka pihak ketiga berlebih. Implementasi ini mengeksekusi antrean secara sekuensial, mengisolasi poison pill ke DLQ, dan melanjutkan task berikutnya tanpa interupsi.
export type TaskHandler = (task: QueueTask) => Promise<void>;
export class OfflineQueueManager {
private tasks: Map<string, QueueTask> = new Map();
private isRunning = false;
constructor(private handler: TaskHandler) {}
public addTask(task: Omit<QueueTask, 'status' | 'retryCount' | 'nextRetryAt' | 'createdAt'>): void {
this.tasks.set(task.id, {
...task,
status: 'pending',
retryCount: 0,
nextRetryAt: 0,
createdAt: Date.now(),
});
}
public getTasksByStatus(status: QueueTask['status']): QueueTask[] {
return Array.from(this.tasks.values()).filter((t) => t.status === status);
}
public async processQueue(): Promise<void> {
if (this.isRunning) return;
this.isRunning = true;
const now = Date.now();
const executableTasks = Array.from(this.tasks.values())
.filter((t) => (t.status === 'pending' || t.status === 'failed') && t.nextRetryAt <= now)
.sort((a, b) => a.createdAt - b.createdAt);
for (const task of executableTasks) {
task.status = 'processing';
try {
await this.handler(task);
// Sukses: Hapus dari antrean
this.tasks.delete(task.id);
} catch (error: any) {
const isFatal = error?.isFatal === true || error?.status === 400 || error?.status === 422;
task.retryCount += 1;
task.lastError = error?.message || 'Unknown execution error';
if (isFatal || task.retryCount >= task.maxRetries) {
// Isolasi ke DLQ
task.status = 'dlq';
} else {
// Jadwalkan retry
task.status = 'failed';
task.nextRetryAt = Date.now() + calculateBackoff(task.retryCount);
}
}
}
this.isRunning = false;
}
// ponytail: Manual resolution via in-memory Map. Gunakan SQLite transaksi jika butuh persistensi disk.
public retryDlqTask(taskId: string): void {
const task = this.tasks.get(taskId);
if (task && task.status === 'dlq') {
task.status = 'pending';
task.retryCount = 0;
task.nextRetryAt = 0;
task.lastError = undefined;
}
}
}4. Verifikasi Logika: Runnable Assertion Test
Kode di bawah membuktikan bahwa task beracun (poison pill) langsung dialihkan ke status dlq dan task valid berikutnya tetap dieksekusi normal.
async function runVerification() {
const executionLog: string[] = [];
const worker = new OfflineQueueManager(async (task) => {
executionLog.push(`Run: ${task.id}`);
if (task.payload === 'POISON_PILL') {
const err: any = new Error('Payload format unprocessable');
err.status = 422; // Fatal error
throw err;
}
});
// Daftarkan dua task: Task 1 beracun, Task 2 valid
worker.addTask({ id: 'task-1', type: 'SYNC_ORDER', payload: 'POISON_PILL', maxRetries: 3 });
worker.addTask({ id: 'task-2', type: 'SYNC_ORDER', payload: 'VALID_DATA', maxRetries: 3 });
// Eksekusi antrean
await worker.processQueue();
const dlqTasks = worker.getTasksByStatus('dlq');
const pendingTasks = worker.getTasksByStatus('pending');
// Assertions
console.assert(dlqTasks.length === 1, 'Harus ada 1 task masuk DLQ');
console.assert(dlqTasks[0].id === 'task-1', 'Task-1 harus diisolasi');
console.assert(dlqTasks[0].status === 'dlq', 'Status Task-1 harus dlq');
console.assert(pendingTasks.length === 0, 'Tidak boleh ada task pending');
console.assert(executionLog.includes('Run: task-2'), 'Task-2 harus tetap dieksekusi');
console.log('Verifikasi DLQ berhasil: Antrean utama tidak terblokir.');
}
runVerification();5. Recovery Data dan Notifikasi Antarmuka (UI)
Task yang masuk ke DLQ tidak boleh hilang tanpa jejak. Data mutasi offline mewakili input pengguna yang belum tersimpan di server.
Penanganan Error pada UI
- Indikator Non-Blocking: Tampilkan icon sinkronisasi berwarna kuning/oranye di status bar atau header form lokal. Hindari memblokir navigasi pengguna dengan modal alert sinkron yang agresif.
- DLQ Inspector Screen: Sediakan menu tersembunyi atau menu debug (pada production, tempatkan di panel Pengaturan Sinkronisasi) untuk menampilkan daftar task gagal beserta alasan error dari
lastError.
Strategi Pemulihan (Resolution Flow)
- Re-edit Payload: Pengguna memperbaiki field formulir yang salah (misal: format tanggal atau ID foreign key yang hilang), kemudian memicu update payload pada storage lokal.
- Manual Retry: Panggil fungsi
retryDlqTask(taskId)setelah koneksi atau kondisi backend sudah tervalidasi. - Drop / Discard: Jika data dianggap usang atau tidak relevan, sediakan opsi bagi pengguna untuk menghapus mutasi lokal dan melakukan penarikan ulang data server (force re-fetch).
Penting: Selalu sertakan
idempotency_key(UUIDv4) pada header setiap task mutasi offline. Ketika task yang sempat berstatus timeout ternyata berhasil diproses oleh backend sebelum masuk DLQ, pengiriman ulang tidak akan menduplikasi rekaman data di server.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!