Masalah Arsitektural: Polling dan Exhaustion Database

Pola umum saat memproses tugas berat (seperti export dataset besar atau sinkronisasi data pihak ketiga) adalah mengalihkannya ke asynchronous queue worker. Masalah muncul ketika frontend berbasis Inertia.js harus menampilkan progres secara interaktif tanpa infrastruktur WebSocket penuh.

Pendekatan naif mengandalkan partial reload Inertia untuk membaca status job langsung dari tabel database operasional (MySQL/PostgreSQL) setiap 1–2 detik:

// Anti-pattern: Polling langsung menyentuh tabel database operasional
public function checkProgress(Request $request)
{
    $job = JobStatus::where('user_id', $request->user()->id)->first();
    return response()->json($job);
}

Dampaknya pada sistem dengan beban menengah hingga tinggi:

  • Connection Pool Exhaustion: Setiap request HTTP meminjam satu koneksi dari pool database. Ratusan tab browser yang melakukan polling simultan menghabiskan batas max_connections dalam hitungan detik.
  • DB Thrashing & I/O Saturation: Query agregasi dan baca-tulis berulang pada tabel yang sama menurunkan throughput transaksi kritis database (seperti order atau pembayaran).
  • Lock Contention: Transaksi polling sering bentrok dengan lock baris/tabel yang sedang diperbarui oleh worker.

Arsitektur Solusi: Redis Cache & Atomic Lock

Solusi standar adalah memisahkan status sementara (ephemeral progress) dari storage relasional. Status komputasi queue disimpan sementara di in-memory store (Redis), dan request polling hanya diizinkan membaca Redis.

Gunakan dua komponen utama:

  1. Redis Atomic Lock: Mencegah race condition ketika pengguna menekan tombol trigger berulang kali.
  2. Cache Key Spesifik (TTL Terbatas): Menyimpan payload progres ringan (status, persentase, URL unduhan) yang dapat dibaca cepat tanpa latensi I/O disk.

Implementasi Backend: Controller & Lazy Evaluation

Inertia menyediakan fitur partial reload via fungsi closure/lazy evaluation. Properti hanya dieksekusi oleh framework jika diminta secara eksplisit via header request X-Inertia-Partial-Data.

namespace App\Http\Controllers;

use App\Jobs\ExportLargeDatasetJob;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;

class ExportController extends Controller
{
    public function index(Request $request): Response
    {
        $userId = $request->user()->id;
        $cacheKey = "export_progress:{$userId}";

        return Inertia::render('Exports/Index', [
            // Evaluasi lazy: Redis hanya diakses saat partial reload meminta 'jobStatus'
            'jobStatus' => fn () => Cache::get($cacheKey, [
                'status' => 'idle',
                'progress' => 0,
            ]),
        ]);
    }

    public function trigger(Request $request)
    {
        $userId = $request->user()->id;
        $lockKey = "export_lock:{$userId}";
        $cacheKey = "export_progress:{$userId}";

        // Atomic lock mencegah duplikasi proses simultan oleh user yang sama
        $lock = Cache::lock($lockKey, 120);

        if (! $lock->get()) {
            return back()->with('error', 'Proses ekspor sedang berjalan.');
        }

        Cache::put($cacheKey, [
            'status' => 'processing',
            'progress' => 0,
        ], now()->addMinutes(30));

        ExportLargeDatasetJob::dispatch($userId, $lockKey, $cacheKey);

        return back();
    }
}

Implementasi Worker Job

Queue worker bertanggung jawab memproses komputasi dan memperbarui Redis. Batasi frekuensi pembaruan cache agar worker tidak membebani network layer Redis.

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Throwable;

class ExportLargeDatasetJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $timeout = 300;

    public function __construct(
        public int $userId,
        public string $lockKey,
        public string $cacheKey
    ) {}

    public function handle(): void
    {
        $totalRows = 1000;

        for ($i = 1; $i <= $totalRows; $i++) {
            // Simulasi proses per chunk data
            usleep(5000);

            // Throttle write ke cache: hanya update setiap kelipatan 100 baris atau baris terakhir
            if ($i % 100 === 0 || $i === $totalRows) {
                $percentage = (int) round(($i / $totalRows) * 100);
                Cache::put($this->cacheKey, [
                    'status' => 'processing',
                    'progress' => $percentage,
                ], now()->addMinutes(10));
            }
        }

        Cache::put($this->cacheKey, [
            'status' => 'completed',
            'progress' => 100,
            'download_url' => "/exports/download/{$this->userId}",
        ], now()->addHours(1));

        Cache::lock($this->lockKey)->forceRelease();
    }

    public function failed(?Throwable $exception): void
    {
        Cache::put($this->cacheKey, [
            'status' => 'failed',
            'progress' => 0,
            'error' => 'Gagal memproses data. Silakan coba kembali.',
        ], now()->addMinutes(15));

        Cache::lock($this->lockKey)->forceRelease();
    }
}

Implementasi Frontend: Polling Aman via Inertia Partial Reload

Di frontend (Vue 3), jalankan router.reload() dengan atribut only. Ini memaksa backend hanya mengeksekusi penarikan data jobStatus dari Redis tanpa mengevaluasi props halaman lainnya.

<script setup>
import { onUnmounted, watch } from 'vue';
import { router } from '@inertiajs/vue3';

const props = defineProps({
    jobStatus: Object,
});

let pollInterval = null;

const startPolling = () => {
    if (pollInterval) return;

    pollInterval = setInterval(() => {
        router.reload({
            only: ['jobStatus'],
            onSuccess: (page) => {
                const currentStatus = page.props.jobStatus?.status;
                if (currentStatus === 'completed' || currentStatus === 'failed') {
                    stopPolling();
                }
            },
            onError: () => stopPolling(),
        });
    }, 2000);
};

const stopPolling = () => {
    if (pollInterval) {
        clearInterval(pollInterval);
        pollInterval = null;
    }
};

watch(
    () => props.jobStatus?.status,
    (newStatus) => {
        if (newStatus === 'processing') {
            startPolling();
        } else {
            stopPolling();
        }
    },
    { immediate: true }
);

onUnmounted(() => stopPolling());
</script>

Menangani Failure Modes: Stale Lock, Crash, dan Eviction

1. Stale Lock Akibat Server Crash

Jika server queue mati mendadak (OOM atau SIGKILL), method failed() tidak dipanggil. Lock database atau Redis berpotensi menggantung permanen. Cegah dengan selalu memberikan durasi lock yang realistis:

// Lock otomatis kedaluwarsa setelah 120 detik
$lock = Cache::lock($lockKey, 120);

Pastikan durasi lock sedikit lebih besar dari batas public int $timeout pada worker job.

2. Deteksi Worker Silent Death

Jika worker berhenti tanpa mengupdate Redis, frontend akan polling tanpa henti. Simpan field updated_at di dalam payload status Redis:

Cache::put($this->cacheKey, [
    'status' => 'processing',
    'progress' => $percentage,
    'last_heartbeat' => now()->timestamp,
]);

Frontend atau Controller memeriksa jika now() - last_heartbeat > 60, ubah status menjadi failed otomatis.

3. Strategi Pembersihan Cache (Memory Leak Prevention)

Hindari menyimpan status selesai tanpa batas kedaluwarsa. Gunakan TTL pendek setelah status terminal tercapai:

  • Status failed: Simpan selama 15 menit agar pesan error sempat terbaca user.
  • Status completed: Simpan selama masa berlaku download URL (misal: 1 jam), kemudian biarkan Redis melakukan volatile-lru eviction.