Sistem antrean worker yang mengeksekusi automasi browser via Playwright rentan terhadap double submission. Kegagalan jaringan sesaat, jeda rendering UI target, atau worker timeout sering memicu mekanisme retry otomatis dari message broker (seperti BullMQ, RabbitMQ, atau SQS). Jika submission pertama sebenarnya sedang diproses di halaman tujuan, eksekusi ulang akan menduplikasi input data. Masalah ini fatal pada use case berisiko tinggi seperti bot tiket, form lelang, atau pendaftaran terbatas.
Akar Masalah: Timeout Submission dan Worker Retry
Form pihak ketiga sering kali tidak menyediakan idempotency key di level endpoint HTTP. Saat automasi mengisi formulir dan menekan tombol submit, response halaman target bisa tertahan (slow response). Worker antrean menganggap task tersebut gagal akibat melebihi visibility timeout, lalu mengirimkan ulang job ke worker lain.
Dua instance browser headless kemudian berjalan paralel dengan muatan payload yang sama. Keduanya mengeksekusi aksi navigasi dan pengisian data yang identik, menghasilkan entri ganda di database target.
Arsitektur Idempotency Key dan Distributed Lock Redis
Pencegahan duplikasi membutuhkan sinkronisasi terdistribusi sebelum browser instance dibuka. Langkah pertama adalah membuat hash konsisten dari payload data unik (misalnya kombinasi NIK/ID Pengguna + ID Acara) sebagai kunci idempotensi.
Kunci ini dikunci menggunakan Redis dengan perintah SET key worker_id NX PX ttl. Flag NX memastikan entri hanya dibuat jika kunci belum ada, sedangkan PX menetapkan masa kedaluwarsa dalam milidetik untuk mencegah deadlock bila worker mati mendadak.
Safe Release dengan Lua Script
Pelepasan lock tidak boleh dilakukan dengan perintah DEL sederhana. Jika worker A mengalami garbage collection pause panjang hingga lock kedaluwarsa dan diambil oleh worker B, pemanggilan DEL oleh worker A akan menghapus lock milik worker B. Penghapusan harus atomik menggunakan script Lua untuk memverifikasi token kepemilikan.
-- Lua Script: Safe Release Lock
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endHeartbeat Lease Extension (Lock Renewal)
Menentukan TTL (Time to Live) statis pada automasi Playwright berisiko. Jika TTL terlalu pendek, lock kedaluwarsa saat interaksi form belum selesai. Jika terlalu panjang, kegagalan worker yang tidak disengaja akan memblokir antrean terlalu lama.
Solusinya adalah Heartbeat Lease Extension. Worker menetapkan TTL pendek (misal: 10 detik), lalu timer periodik (misal: setiap 4 detik) memperpanjang usia lock via Lua script selama browser masih aktif bekerja.
-- Lua Script: Heartbeat Renewal
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
endImplementasi Kode Worker Antrean Playwright
Contoh implementasi Node.js berikut mengintegrasikan client Redis, mekanisme heartbeat, isolated browser context, dan release lock secara atomik.
import { chromium } from 'playwright';
import Redis from 'ioredis';
import crypto from 'node:crypto';
const redis = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
const LUA_RELEASE = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const LUA_HEARTBEAT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
`;
export async function processSubmissionJob(jobPayload) {
// 1. Buat idempotency key deterministik dari payload penting
const payloadHash = crypto.createHash('sha256')
.update(JSON.stringify({ id: jobPayload.userId, eventId: jobPayload.eventId }))
.digest('hex');
const lockKey = `lock:submission:${payloadHash}`;
const workerId = crypto.randomUUID();
const ttlMs = 10000;
// 2. Akuisisi Lock (SET NX PX)
const acquired = await redis.set(lockKey, workerId, 'PX', ttlMs, 'NX');
if (!acquired) {
console.warn(`[SKIP] Job duplikat terdeteksi atau sedang diproses: ${lockKey}`);
return { status: 'skipped', reason: 'locked' };
}
// 3. Setup Heartbeat Timer
const heartbeatInterval = setInterval(async () => {
try {
const renewed = await redis.eval(LUA_HEARTBEAT, 1, lockKey, workerId, ttlMs);
if (renewed !== 1) {
console.error(`[LOCK LOST] Gagal renew lock untuk key: ${lockKey}`);
}
} catch (err) {
console.error('[HEARTBEAT ERROR]', err.message);
}
}, ttlMs / 2);
let browser = null;
let context = null;
try {
// 4. Launch Browser Terisolasi
browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage', '--no-sandbox']
});
context = await browser.newContext();
const page = await context.newPage();
// Eksekusi Flow Form
await page.goto(jobPayload.targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.fill('#user-id', jobPayload.userId);
await page.click('#submit-btn');
await page.waitForSelector('.success-receipt', { timeout: 15000 });
return { status: 'success' };
} finally {
// 5. Cleanup Resources & Matikan Heartbeat
clearInterval(heartbeatInterval);
if (context) await context.close().catch(() => {});
if (browser) await browser.close().catch(() => {});
// 6. Safe Release Lock
await redis.eval(LUA_RELEASE, 1, lockKey, workerId);
}
}Graceful Termination dan Penanganan Zombie Browser
Browser automasi Chromium dijalankan sebagai sub-proses terpisah. Jika worker memproses job lalu menerima sinyal terminasi sistem operasi (SIGTERM atau SIGINT saat deployment pod Kubernetes), proses Node.js dapat berhenti seketika sebelum blok finally tereksekusi. Akibatnya:
- Proses Chromium tetap hidup di memori sebagai zombie process, mengakibatkan akumulasi memory leak.
- Distributed lock tetap tertahan hingga TTL berakhir tanpa pembersihan yang semestinya.
Terapkan signal traps pada level entry point worker process:
const activeBrowsers = new Set();
// Daftarkan browser instance ke registry lokal saat launch
export function trackBrowser(browserInstance) {
activeBrowsers.add(browserInstance);
browserInstance.on('disconnected', () => activeBrowsers.delete(browserInstance));
}
async function handleExit(signal) {
console.log(`Menerima ${signal}. Mematikan semua instance browser...`);
for (const browser of activeBrowsers) {
await browser.close().catch(() => {});
}
process.exit(0);
}
process.on('SIGTERM', () => handleExit('SIGTERM'));
process.on('SIGINT', () => handleExit('SIGINT'));Pertimbangan Produksi dan Trade-offs
- Browser Context vs New Instance: Membuka
chromium.launch()per task menjamin isolasi memori total, tetapi lambat (overhead 500-1500ms). Menggunakanbrowser.newContext()dari satu browser instance pooling menghemat CPU/RAM, tetapi rentan terhadap kebocoran resource jika browser utama crash. - Network Failures saat Release: Jika Redis gagal diakses saat pelepasan lock di blok
finally, sistem tetap aman karena TTL dasar akan membebaskan kunci tanpa intervensi manual setelah interval heartbeat mati. - Alternatif API: Selalu cek apakah endpoint form target menerima submission via HTTP request (cURL/fetch) murni. Menjalankan browser engine Playwright hanya boleh menjadi opsi terakhir saat ada enkripsi payload di sisi client, proteksi bot berbasis CAPTCHA/Turnstile, atau token CSRF dinamis yang rumit.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!