Anatomi Masalah: Orphaned Lock Pasca-Mutasi Inertia

Saat aplikasi berbasis Inertia.js mengeksekusi mutasi (misalnya melalui router.post) yang membutuhkan proteksi konkurensi, pola umum yang diterapkan adalah menggunakan distributed lock di Redis melalui backend (misalnya Laravel). Pola ini mencegah pengguna memicu operasi identik secara paralel, seperti pembuatan invoice ganda atau ekspor data masif.

Masalah muncul ketika mutasi tersebut mendelegasikan eksekusi berat ke background worker. Jika worker mengalami Out-Of-Memory (OOM) atau menerima sinyal SIGKILL (sinyal 9) dari OS saat memproses job, proses worker mati seketika. Karena SIGKILL tidak dapat di-intercept oleh runtime PHP, blok kode finally atau destructor objek tidak akan pernah dieksekusi. Jika lock tidak memiliki limitasi masa aktif (TTL) yang ketat, kunci Redis tersebut berstatus orphaned.

Dampaknya langsung terasa pada antarmuka Inertia. Pengguna menerima indikasi proses gagal atau halaman refresh secara partial, namun saat mencoba menekan tombol kembali, permintaan ditolak karena lock sebelumnya masih aktif di Redis. Sistem mengalami deadlock fungsional.

1. Konfigurasi TTL Deterministik pada Redis Lock

Kesalahan mendasar pada distributed lock adalah membiarkan lock hidup tanpa batas waktu, atau menetapkan TTL yang terlalu tinggi dengan asumsi worker akan selalu berhasil merilisnya melalui $lock->release().

Secara internal, Redis mengimplementasikan atomic locking menggunakan perintah berikut:

SET lock:export:user:42 "owner-token-uuid" NX PX 30000

Di layer aplikasi Laravel, jangan pernah memperoleh lock tanpa menentukan batas waktu kadaluarsa (TTL). Terapkan batas waktu realistis yang sedikit melampaui estimasi waktu job normal:

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

// Memperoleh lock dengan TTL 30 detik
$owner = (string) Str::uuid();
$lock = Cache::lock('process:order:1024', 30, $owner);

if ($lock->get()) {
    ProcessOrderJob::dispatch('process:order:1024', $owner);
} else {
    // Lock gagal diperoleh: request sedang diproses worker lain
}

Dengan menyertakan parameter detik, Redis otomatis membuang kunci jika worker crash sebelum memanggil rilis. Ini adalah jaring pengaman lini pertama.

2. Heartbeat Renewal untuk Task Berdurasi Panjang

Menentukan TTL lock menghadirkan dilema: jika TTL terlalu pendek, lock kedaluwarsa saat worker masih bekerja valid (memicu race condition); jika TTL terlalu panjang, worker crash akan menahan aksi pengguna di UI terlalu lama.

Solusinya adalah Heartbeat / Lease Renewal. Berikan TTL pendek (misal 15 detik) ke Redis lock, lalu perpanjang TTL tersebut secara periodik selama proses berjalan normal.

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Redis;

class HeavyProcessingJob implements ShouldQueue
{
    use Queueable;

    public function __construct(
        protected string $lockKey,
        protected string $ownerToken
    ) {}

    public function handle(): void
    {
        // Script Lua untuk atomic renewal: hanya renew jika owner match
        $renewLua = <<<'LUA'
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('PEXPIRE', KEYS[1], ARGV[2])
            else
                return 0
            end
        LUA;

        $items = range(1, 100);

        foreach ($items as $item) {
            // Jalankan subset batch kerja
            $this->processChunk($item);

            // Heartbeat: perpanjang TTL lock sebesar 15000 ms (15 detik)
            Redis::eval($renewLua, 1, $this->lockKey, $this->ownerToken, 15000);
        }

        // Rilis lock secara aman di akhir tugas
        $releaseLua = <<<'LUA'
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            else
                return 0
            end
        LUA;

        Redis::eval($releaseLua, 1, $this->lockKey, $this->ownerToken);
    }
}

Jika worker terbunuh (SIGKILL/OOM) di tengah perulangan, heartbeat terhenti seketika. Dalam durasi maksimal 15 detik kemudian, Redis akan menghapus lock secara otomatis.

3. Graceful Shutdown Worker: Menangani SIGTERM vs SIGKILL

Saat deployment atau scaling pod Kubernetes / proses Supervisor, proses worker dikirimi sinyal penghentian. Runner yang dikonfigurasi dengan buruk langsung mengirim SIGKILL, menghentikan eksekusi tanpa pembersihan.

Konfigurasi Supervisor harus memberikan ruang bagi worker menyelesaikan job atau menangani SIGTERM (graceful shutdown) sebelum dipaksa mati via SIGKILL.

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --timeout=120
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=130

Nilai stopwaitsecs wajib lebih besar daripada parameter --timeout worker. Jika worker diberikan batas timeout 120 detik, biarkan stopwaitsecs bernilai 130 detik. Ini memastikan PHP runtime sempat menangkap timeout dan melepas distributed lock via exception handler sebelum Supervisor mengeksekusi SIGKILL.

4. Fallback Controller & State Reconciliation di Inertia

Di frontend, pengguna yang menemui kondisi locked memerlukan kejelasan status, bukan hang atau silent failure. Controller harus membedakan antara aksi yang sedang berjalan normal dengan lock yang terindikasi stale.

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as HttpResponse;

class ReportExportController
{
    public function export(Request $request)
    {
        $userId = $request->user()->id;
        $lockKey = "export:report:{$userId}";
        $lock = Cache::lock($lockKey, 20);

        if (! $lock->get()) {
            return back()->withErrors([
                'lock' => 'Proses ekspor data sedang berlangsung. Harap tunggu beberapa detik.'
            ])->setStatusCode(HttpResponse::HTTP_LOCKED);
        }

        dispatch(new GenerateReportJob($lockKey, $lock->owner()));

        return back()->with('status', 'Ekspor dijadwalkan.');
    }
}

Di komponen Inertia (Vue/React), handle status error 423 (Locked) dengan menyajikan polling periodik atau tombol coba lagi yang aktif otomatis mengikuti sisa TTL backend:

// Inertia Form submit handler di frontend
import { useForm } from '@inertiajs/vue3';

const form = useForm({});

function triggerExport() {
  form.post('/reports/export', {
    onError: (errors) => {
      if (errors.lock) {
        // Polling partial data status menggunakan router.reload
        const interval = setInterval(() => {
          router.reload({
            only: ['flash', 'errors'],
            onSuccess: () => clearInterval(interval)
          });
        }, 5000);
      }
    }
  });
}

5. Skenario Pengujian Ketahanan: Simulasi Forced Crash

Untuk memverifikasi keandalan konfigurasi lock saat worker crash, jalankan skenario chaos test terisolasi di server development:

  1. Jalankan Worker Queue:
    php artisan queue:work redis --queue=default
  2. Ambil PID Proses Worker:
    pgrep -f "queue:work redis"
  3. Trigger Job Melalui Inertia UI: Klik aksi pada aplikasi yang memicu perolehan Redis lock dan job execution.
  4. Kirim Sinyal Pembunuhan Paksa: Sebelum job selesai (misalnya pada detik ke-3), kirim SIGKILL ke worker:
    kill -9 <WORKER_PID>
  5. Evaluasi Status Kunci Redis: Pantau sisa waktu kunci di CLI:
    redis-cli TTL export:report:1
  6. Verifikasi Resiliensi UI: Coba klik tombol submit kembali pada frontend Inertia. Amati respon UI: aplikasi harus memblokir request selama sisa TTL, kemudian mengizinkan eksekusi ulang secara normal tepat setelah TTL mencapai nilai -2 (key expired) tanpa intervensi manual developer.
Catatan Penting: Jangan gunakan manual Cache::forget() di controller untuk membuka lock yang gagal tanpa memeriksa validasi owner token. Membuka lock sembarangan dapat merusak atomisitas jika worker yang dianggap mati ternyata hanya mengalami lag jaringan sementara.