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 fungsipg_advisory_unlockdipanggil. 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 melaluiCOMMIT,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:
- Jika container Next.js mati di tengah eksekusi, koneksi TCP ke PostgreSQL terputus.
- PostgreSQL secara otomatis mendeteksi koneksi terputus dan membatalkan transaksi yang sedang berjalan (
ROLLBACK). - 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
| Kriteria | PostgreSQL Advisory Lock | Redis (SET NX / Redlock) |
|---|---|---|
| Kompleksitas Infrastruktur | Nol jika PostgreSQL sudah digunakan | Membutuhkan instance/cluster Redis terpisah |
| Ketahanan Node Crash | Otomatis release via lifecycle transaksi DB | Bergantung pada konfigurasi TTL expiration |
| Risiko Clock Drift | Tidak ada (state berada di 1 engine ACID) | Rentan terhadap time synchronization failure |
| Throughput Kapasitas | Optimal 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).
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!