Anatomi Masalah: Race Condition Stale Write
Pola revalidasi asinkron seperti Stale-While-Revalidate (SWR) atau mutasi optimistik sering mengalami race condition pada jaringan seluler yang fluktuatif. Masalah terjadi saat siklus berikut berjalan:
- Komponen memicu pengambilan data di latar belakang pada $T_1$ (permintaan jaringan lambat).
- Pengguna melakukan aksi lokal pada $T_2$ ($T_1 < T_2$), seperti memperbarui status bookmark, yang langsung menulis state baru ke storage lokal.
- Respons dari $T_1$ baru tiba pada $T_3$ ($T_3 > T_2$). Handler respons yang naif langsung menulis payload usang tersebut ke storage dan state UI.
Akibatnya, data mutasi lokal pada $T_2$ tertimpa oleh respons lama dari $T_1$. Fenomena ini memicu UI glitch di mana input pengguna tiba-tiba berbalik ke nilai lama tanpa ada galat jaringan yang eksplisit.
Arsitektur 3-Lapisan Pencegahan Stale Write
Pencegahan inkonsistensi ini memerlukan tiga lapisan proteksi yang saling melengkapi:
- AbortController Pool: Menghentikan siklus hidup HTTP request in-flight yang sudah usang begitu ada mutasi atau permintaan baru untuk kunci cache yang sama.
- Metadata Envelope & Versioning Monotonik: Membungkus payload dengan stempel waktu terverifikasi atau nomor urut yang bertambah secara monotonik. Data hanya valid jika versinya lebih besar dari versi tersimpan.
- Atomic Write-Guard: Memanfaatkan sifat sinkron dari storage performa tinggi seperti MMKV untuk melakukan validasi versi sesaat sebelum persistensi dieksekusi.
Implementasi: CacheManager dengan TypeScript
Kode berikut mengimplementasikan pengelola cache yang menggabungkan pembatalan request, metadata envelope, dan atomic write-guard.
import { MMKV } from 'react-native-mmkv';
export interface CacheEnvelope<T> {
data: T;
version: number;
}
export interface StorageAdapter {
getString(key: string): string | undefined;
set(key: string, value: string): void;
}
export class GuardedCacheManager {
private abortControllers = new Map<string, AbortController>();
private storage: StorageAdapter;
constructor(storageInstance?: StorageAdapter) {
this.storage = storageInstance ?? new MMKV();
}
private getStoredEnvelope<T>(key: string): CacheEnvelope<T> | null {
const raw = this.storage.getString(key);
if (!raw) return null;
try {
return JSON.parse(raw) as CacheEnvelope<T>;
} catch {
return null;
}
}
public get<T>(key: string): T | null {
const envelope = this.getStoredEnvelope<T>(key);
return envelope ? envelope.data : null;
}
public mutate<T>(key: string, updater: (current: T | null) => T): T {
// 1. Batalkan fetch in-flight yang sedang berjalan untuk key ini
this.abortOngoingRequest(key);
const currentEnvelope = this.getStoredEnvelope<T>(key);
const nextData = updater(currentEnvelope ? currentEnvelope.data : null);
const nextVersion = Date.now();
// 2. Atomic write guard: pastikan versi mutasi selalu maju
const newEnvelope: CacheEnvelope<T> = {
data: nextData,
version: Math.max(nextVersion, (currentEnvelope?.version ?? 0) + 1),
};
this.storage.set(key, JSON.stringify(newEnvelope));
return nextData;
}
public async fetchAndRevalidate<T>(
key: string,
fetcher: (signal: AbortSignal) => Promise<T>
): Promise<T | null> {
// Batalkan request sebelumnya untuk cache key yang sama
this.abortOngoingRequest(key);
const controller = new AbortController();
this.abortControllers.set(key, controller);
const requestInitiatedAt = Date.now();
try {
const freshData = await fetcher(controller.signal);
// Write-guard: cek apakah storage telah diubah selama in-flight fetch
const currentStored = this.getStoredEnvelope<T>(key);
if (currentStored && currentStored.version > requestInitiatedAt) {
// Data usang terdeteksi: buang respons jaringan
return null;
}
const envelope: CacheEnvelope<T> = {
data: freshData,
version: requestInitiatedAt,
};
this.storage.set(key, JSON.stringify(envelope));
return freshData;
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
return null;
}
throw error;
} finally {
if (this.abortControllers.get(key) === controller) {
this.abortControllers.delete(key);
}
}
}
public abortOngoingRequest(key: string): void {
const controller = this.abortControllers.get(key);
if (controller) {
controller.abort();
this.abortControllers.delete(key);
}
}
}
ponytail: write-guard menggunakan serialisasi JSON langsung. skipped: per-key mutex lock, add when asynchronous disk adapter (AsyncStorage) menggantikan synchronous MMKV.
Integrasi React Hook & Penanganan Unmount
Komponen UI React Native dapat unmount sebelum permintaan async selesai. Gunakan AbortController di dalam useEffect untuk membersihkan proses dan mencegah memory leak atau pembaruan state pada komponen yang mati.
import { useEffect, useState, useCallback } from 'react';
import { GuardedCacheManager } from './GuardedCacheManager';
const cacheManager = new GuardedCacheManager();
export function useGuardedCache<T>(
key: string,
fetcher: (signal: AbortSignal) => Promise<T>
) {
const [data, setData] = useState<T | null>(() => cacheManager.get<T>(key));
useEffect(() => {
let isMounted = true;
cacheManager
.fetchAndRevalidate(key, fetcher)
.then((result) => {
if (isMounted && result !== null) {
setData(result);
}
})
.catch(() => {
// Handle fetch error
});
return () => {
isMounted = false;
cacheManager.abortOngoingRequest(key);
};
}, [key, fetcher]);
const mutate = useCallback(
(updater: (current: T | null) => T) => {
const next = cacheManager.mutate(key, updater);
setData(next);
},
[key]
);
return { data, mutate };
}
Validasi Teknis: Runnable Assert Test
Skrip berikut membuktikan bahwa respon jaringan yang lambat (in-flight) tidak akan menimpa data hasil mutasi lokal yang dilakukan setelahnya.
import assert from 'node:assert';
// Mock storage adapter synchronous (memory-based)
class MockMMKV implements StorageAdapter {
private store = new Map<string, string>();
getString(key: string): string | undefined {
return this.store.get(key);
}
set(key: string, value: string): void {
this.store.set(key, value);
}
}
async function runStaleWriteTest() {
const cache = new GuardedCacheManager(new MockMMKV());
const CACHE_KEY = 'user_profile';
// 1. Set initial state
cache.mutate(CACHE_KEY, () => ({ id: '1', name: 'Original Name' }));
// 2. Simulasikan fetch jaringan lambat (resolves 100ms)
const slowFetchPromise = cache.fetchAndRevalidate(CACHE_KEY, async (signal) => {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
resolve({ id: '1', name: 'Stale Server Response' });
}, 100);
signal.addEventListener('abort', () => {
clearTimeout(timer);
const err = new Error('Aborted');
err.name = 'AbortError';
reject(err);
});
});
});
// 3. Simulasikan mutasi lokal cepat oleh user pada 20ms
await new Promise((r) => setTimeout(r, 20));
cache.mutate(CACHE_KEY, () => ({ id: '1', name: 'Mutated By User' }));
// 4. Tunggu fetch awal selesai atau diabaikan
await slowFetchPromise;
// 5. Assert: cache lokal harus tetap berisi data mutasi lokal terbaru
const finalData = cache.get<{ id: string; name: string }>(CACHE_KEY);
assert.strictEqual(
finalData?.name,
'Mutated By User',
'FAIL: Respon stale server menimpa mutasi lokal terbaru!'
);
console.log('PASS: Stale write berhasil dicegah.');
}
runStaleWriteTest().catch((err) => {
console.error(err);
process.exit(1);
});
Trade-off dan Batasan
- Sinkronisasi Jam Klien: Versioning berbasis
Date.now()bergantung pada jam internal perangkat. Jika pengguna memundurkan jam sistem secara manual, gunakan sequential counter berbasis atomic integer yang disimpan di storage lokal. - Pembatalan Jaringan Native:
AbortControllerdi React Native bergantung pada bridgingfetch(Networking module). Pada Android dan iOS, abort signal akan memutuskan koneksi HTTP soket level platform secara langsung. - Payload Cache Size: Pembungkusan objek dalam envelope metadata menambahkan overhead memori minimal (~25 bytes per entri), yang terabaikan dibanding proteksi konkurensi yang didapatkan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!