Aplikasi web modern berbasis arsitektur polimorfik seperti Primate memproses request statis, API, dan komponen sisi server (SSR) dalam siklus hidup terpadu. Saat mengelola stateful session pada cluster node yang mengakses shared Key-Value (KV) store terdistribusi (seperti Valkey, Redis, atau Cloudflare KV), lonjakan konkurensi sering memicu lock contention. Kondisi ini muncul ketika banyak request bersamaan mencoba memvalidasi, memperbarui, atau memperpanjang session token yang sama.

Artikel ini membahas strategi konkret meredam lock contention pada stateful session cache Primate melalui pola lease locking, optimasi cache-aside dengan sliding TTL, dan fallback atomic Compare-And-Swap (CAS).

Akar Masalah: Bottleneck KV Store pada Concurrent Session

Saat request paralel masuk untuk session yang sama (misalnya polling frontend atau asset fetching berbasis session), setiap worker thread mencoba membaca dan menulis ke key session yang sama. Pola naif yang menerapkan pessimistic lock (misalnya SET key val NX EX) memicu antrean thread worker yang menunggu giliran, menyebabkan:

  • Connection starvation: Pool koneksi KV store habis karena request tertahan menunggu pelepasan lock.
  • Thundering herd problem: Saat lock dilepas atau cache kadaluarsa secara bersamaan, puluhan request mengeksekusi revalidasi session ke database backend secara serempak.
  • P99 Latency degradation: Request ringan mengalami lonjakan latensi ratusan milidetik hanya untuk membaca state session.

Strategi 1: Lease Locking dengan Exponential Backoff

Daripada memblokir seluruh operasi baca-tulis, pola lease locking membedakan antara kebutuhan membaca state dengan hak memperbarui (lease). Hanya satu worker yang diizinkan memperbarui data ke storage persistensi backend, sementara request lain membaca salinan terakhir (stale-while-revalidate) atau menunggu dengan exponential backoff dan jitter.

import crypto from "node:crypto";

class SessionStore {
  constructor(kvClient) {
    this.kv = kvClient;
  }

  async acquireLease(sessionId, ttlMs = 1500) {
    const token = crypto.randomUUID();
    // SET NX PX menjamin atomisitas perolehan lock
    const acquired = await this.kv.set(`lock:session:${sessionId}`, token, {
      nx: true,
      px: ttlMs
    });
    return acquired ? token : null;
  }

  async releaseLease(sessionId, token) {
    // Lua script untuk memastikan hanya pemilik token yang dapat melepas lock
    const script = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      else
        return 0
      end
    `;
    return this.kv.eval(script, 1, `lock:session:${sessionId}`, token);
  }
}

Strategi 2: Cache-Aside dengan Sliding TTL dan Mutex Fallback

Untuk menghindari lock contention saat memperpanjang durasi sesi (touch TTL), terapkan pemisahan interval refresh: jangan perbarui KV TTL pada setiap request baca. Cukup perbarui jika sisa TTL di bawah threshold tertentu (misalnya separuh dari durasi total).

Jika cache miss terjadi secara simultan, gunakan locking berbasis CAS untuk revalidasi:

async function getSessionWithBackoff(sessionStore, sessionId, maxRetries = 3) {
  let attempt = 0;
  const baseDelayMs = 25;

  while (attempt < maxRetries) {
    const cached = await sessionStore.kv.get(`session:${sessionId}`);
    if (cached) {
      const session = JSON.parse(cached);
      // Sliding TTL: Hanya perpanjang jika sisa umur < 50%
      const now = Date.now();
      if (now - session.lastTouch > 300_000) { // 5 menit
        session.lastTouch = now;
        // Background async update, non-blocking ke request pipeline
        sessionStore.kv.set(`session:${sessionId}`, JSON.stringify(session), {
          xx: true,
          ex: 1800 // 30 menit
        }).catch(() => {});
      }
      return session;
    }

    // Atomic CAS / Lease: ambil hak fetch ke database
    const leaseToken = await sessionStore.acquireLease(sessionId);
    if (leaseToken) {
      try {
        const freshData = await fetchSessionFromDB(sessionId);
        await sessionStore.kv.set(`session:${sessionId}`, JSON.stringify(freshData), {
          ex: 1800
        });
        return freshData;
      } finally {
        await sessionStore.releaseLease(sessionId, leaseToken);
      }
    }

    // Gagal memperoleh lease, tunggu backoff dengan full jitter
    attempt++;
    const delay = Math.random() * (baseDelayMs * Math.pow(2, attempt));
    await new Promise((resolve) => setTimeout(resolve, delay));
  }

  // Fallback: Query langsung ke DB bila lease timeout berulang
  return fetchSessionFromDB(sessionId);
}

Integrasi pada Middleware Primate

Dalam runtime Primate, pasang modul penanganan sesi ini sebagai handler request lifecycle. Hindari synchronous waiting di dalam dispatch handler.

// src/hooks/session.js
export default function sessionHook(store) {
  return async (request, next) => {
    const sessionId = request.cookies.get("sid");
    if (!sessionId) {
      return next(request);
    }

    // Resolusi session stateful tanpa lock contention
    const session = await getSessionWithBackoff(store, sessionId);
    request.session = session;

    return next(request);
  };
}

Metrik Observabilitas Latensi Cache

Pantau pipeline caching menggunakan telemetri spesifik untuk mengidentifikasi perburukan lock sebelum sistem kolaps:

  • cache.lock.acquire.duration: Histogram durasi perolehan lease. P99 di atas 50ms mengindikasikan backoff delay terlalu agresif atau contention tinggi.
  • cache.lock.contention.rate: Rasio kegagalan acquireLease pada percobaan pertama. Nilai di atas 5% memerlukan penyesuaian strategi jitter.
  • cache.hit.stale_ratio: Frekuensi penyajian data stale-while-revalidate saat pembaruan berlangsung di background.
  • kv.command.eval.latency: Waktu eksekusi Lua script CAS pada cluster database.

Checklist Penanganan Operational Failure

  1. Deadlock Prevention: Selalu atur TTL hard limit pada lock lease (maksimal 2-3 detik) agar crash pada worker tidak menyandera key secara permanen.
  2. Circuit Breaker ke DB Backend: Jika shared KV cluster mengalami partisi jaringan (partition error), batasi direct DB fallback menggunakan circuit breaker untuk melindungi database relasional utama dari beban kejut.
  3. Unlink daripada Delete: Jika session invalidasi massal diperlukan (misalnya logout semua device), gunakan instruksi non-blocking UNLINK alih-alih DEL untuk mencegah blocking pada thread engine KV.
  4. Jitter Distribution: Pastikan algoritma exponential backoff menyertakan rentang acak penuh (full jitter) guna mencegah node worker bangun dan menembak KV store di detik yang identik.