Menjalankan tugas terjadwal (cron job) melalui Route Handler Next.js (misalnya app/api/cron/sync/route.ts) adalah pola umum pada arsitektur modern. Cron eksternal seperti Vercel Cron, GitHub Actions, atau Cloudflare Workers mengirim HTTP request secara periodik untuk memicu eksekusi kode backend.

Masalah muncul ketika waktu eksekusi job melebihi interval trigger. Jika sinkronisasi data butuh waktu 90 detik sementara cron berjalan setiap 60 detik, instance baru akan dipicu sebelum instance sebelumnya selesai. Pada lingkungan serverless dan autoscaling multi-instance, hal ini menghasilkan overlapping runs yang memicu race condition, duplikasi data, lonjakan beban database, hingga deadlock.

Akar Masalah: Konkurensi Tanpa State Terpusat

Serverless Route Handler bersifat stateless. Variabel memori lokal seperti flag let isRunning = false; tidak dapat dibagi antar instance yang berbeda. Dua container atau microVM terpisah tidak mengetahui aktivitas satu sama lain.

Sebagian engineer langsung menambahkan Redis hanya untuk mengimplementasikan distributed lock dengan mekanisme SET NX EX atau Redlock. Pendekatan ini melanggar prinsip kesederhanaan jika aplikasi Anda sudah menggunakan PostgreSQL. Redis menambah dependensi infrastruktur baru, biaya operasional, serta risiko sinkronisasi TTL yang rawan gagal saat network drift.

Solusi Native: PostgreSQL Advisory Lock

PostgreSQL menyediakan fitur Advisory Lock. Berbeda dengan row-level lock (seperti SELECT FOR UPDATE) yang mengunci baris data fisik, advisory lock dirancang khusus untuk koordinasi tingkat aplikasi menggunakan ruang kunci (key space) arbitrer berupa integer 64-bit.

Session Lock vs Transaction Lock

Terdapat dua jenis advisory lock di PostgreSQL:

  • Session-level (pg_try_advisory_lock): Lock bertahan hingga koneksi database ditutup secara eksplisit atau fungsi pg_advisory_unlock dipanggil. Sangat berbahaya di lingkungan serverless atau saat menggunakan connection pooler (seperti PgBouncer dalam mode transaction pooling), karena koneksi bisa dipinjamkan kembali ke request lain saat lock masih aktif.
  • Transaction-level (pg_try_advisory_xact_lock): Lock terikat langsung pada siklus transaksi database. Lock otomatis dilepas saat transaksi selesai (baik melalui COMMIT, ROLLBACK, maupun saat koneksi terputus/timeout). Ini adalah varian yang paling aman untuk Route Handler Next.js.

Implementasi pada Route Handler Next.js

Berikut adalah implementasi non-blocking cron lock menggunakan Prisma ORM. Pendekatan serupa berlaku secara identik jika Anda menggunakan Drizzle ORM atau driver native pg.

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

// BigInt konstan unik untuk mengidentifikasi cron job sinkronisasi inventaris
// Alternatif: gunakan SELECT hashtext('sync-inventory')::bigint
const CRON_LOCK_ID = 8492019482019283n;

export async function GET(request: Request) {
  // 1. Verifikasi header otorisasi dari cron scheduler eksternal
  const authHeader = request.headers.get("authorization");
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const startTime = performance.now();

  try {
    const result = await prisma.$transaction(
      async (tx) => {
        // 2. Coba peroleh lock (non-blocking)
        const lockResult = await tx.$queryRaw<[{ acquired: boolean }]>`
          SELECT pg_try_advisory_xact_lock(${CRON_LOCK_ID}) AS acquired;
        `;

        const isLocked = lockResult[0]?.acquired ?? false;

        if (!isLocked) {
          return { status: "skipped", reason: "Job already running on another instance" };
        }

        // 3. Eksekusi proses bisnis yang memakan waktu
        await executeHeavyTask(tx);

        return { status: "completed" };
      },
      {
        // Naikkan timeout transaksi Prisma jika job berjalan lama
        timeout: 120000, 
        maxWait: 5000,
      }
    );

    const duration = Math.round(performance.now() - startTime);

    // 4. Return HTTP response sesuai status eksekusi
    if (result.status === "skipped") {
      // Return 200 OK dengan payload skipped untuk mencegah alert palsu pada scheduler
      return NextResponse.json(
        { ...result, durationMs: duration },
        { status: 200 }
      );
    }

    return NextResponse.json(
      { ...result, durationMs: duration },
      { status: 200 }
    );
  } catch (error) {
    const duration = Math.round(performance.now() - startTime);
    console.error("[Cron Error] Execution failed:", { error, durationMs: duration });

    return NextResponse.json(
      { status: "error", message: (error as Error).message, durationMs: duration },
      { status: 500 }
    );
  }
}

async function executeHeavyTask(tx: any) {
  // Simulasi pemrosesan batch
  // Pastikan operasi write menggunakan instance `tx` agar berada dalam transaksi yang sama
  await new Promise((resolve) => setTimeout(resolve, 5000));
}

Keputusan HTTP Status Code: 409 Conflict vs 200 OK

Saat eksekusi diabaikan (lock gagal diperoleh), pemilihan response code bergantung pada perilaku engine cron Anda:

  • HTTP 200 OK (Recommended untuk Vercel Cron / Cloudflare): Scheduler memperlakukan request sebagai sukses. Mencegah webhook notifikasi alert/pager berbunyi akibat kegagalan semu (false positive) ketika job memang sengaja di-skip karena instance sebelumnya masih berjalan.
  • HTTP 409 Conflict: Tepat jika scheduler memiliki konfigurasi retry berbasis exponential backoff atau jika Anda menggunakan workflow engine seperti QStash yang butuh tanda eksplisit bahwa resource sedang sibuk untuk dijadwalkan ulang.

Penanganan Fail-Safe dan Auto-Release

Salah satu risiko fatal pada distributed lock adalah deadlock abadi: sebuah job crash di tengah jalan, instance mati mendadak (out-of-memory atau shutdown timeout), dan lock tidak pernah dilepas.

Dengan pg_try_advisory_xact_lock:

  1. Jika container Next.js mati di tengah eksekusi, koneksi TCP ke PostgreSQL terputus.
  2. PostgreSQL secara otomatis mendeteksi koneksi terputus dan membatalkan transaksi yang sedang berjalan (ROLLBACK).
  3. Lock seketika dilepaskan oleh PostgreSQL kernel. Instance cron berikutnya pada jadwal berikutnya dapat langsung memperoleh lock tanpa campur tangan manual.

Catatan untuk Connection Pooler: Jika Anda menggunakan connection pooler seperti Supabase Connection Pooler atau Prisma Accelerate dalam mode Transaction Pooling, pastikan advisory lock selalu dieksekusi di dalam blok $transaction. Jangan pernah menggunakan session-level lock (pg_advisory_lock) karena koneksi underlying dapat berganti di tengah request.

Observabilitas dan Metrik Eksekusi

Advisory lock menyederhanakan pelacakan bottleneck. Dengan mengukur durasi menggunakan performance.now() dan mencatat statusnya ke log terstruktur, Anda dapat mengidentifikasi degradasi performa:

  • Jika log sering menunjukkan status skipped, interval jadwal cron Anda terlalu rapat dibanding durasi pemrosesan rata-rata data.
  • Pantau durasi transaksi melalui view sistem PostgreSQL untuk mendeteksi transaksi yang menggantung:
SELECT pid, locktype, objid, granted, query 
FROM pg_locks l 
JOIN pg_stat_activity a ON l.pid = a.pid 
WHERE l.locktype = 'advisory';

Evaluasi: PostgreSQL Advisory Lock vs Redis Distributed Lock

KriteriaPostgreSQL Advisory LockRedis (SET NX / Redlock)
Kompleksitas InfrastrukturNol jika PostgreSQL sudah digunakanMembutuhkan instance/cluster Redis terpisah
Ketahanan Node CrashOtomatis release via lifecycle transaksi DBBergantung pada konfigurasi TTL expiration
Risiko Clock DriftTidak ada (state berada di 1 engine ACID)Rentan terhadap time synchronization failure
Throughput KapasitasOptimal untuk frekuensi cron (menit/jam)Lebih unggul untuk ribuan lock per detik

Gunakan PostgreSQL Advisory Lock untuk seluruh kebutuhan background job berbasis cron pada Next.js. Beralihlah ke Redis hanya jika aplikasi Anda mengeksekusi puluhan ribu lock terdistribusi dalam hitungan detik (high-throughput rate-limiting).