Dalam pengembangan aplikasi web B2B dan portal enterprise, banyak operasi bisnis yang membutuhkan waktu eksekusi relatif lama: menerbitkan laporan keuangan berformat PDF, menyinkronkan ribuan inventaris stok ke ERP pihak ketiga, mengirimkan notifikasi multi-saluran (Email, SMS, WhatsApp), hingga memproses kalkulasi analitik kompleks.
Jika operasi-operasi ini dieksekusi secara sinkron di dalam siklus request-response HTTP utama, dua dampak buruk akan langsung dirasakan:
- Pengguna mengalami waktu tunggu yang sangat lama (latensi HTTP membengkak dari 50 milidetik menjadi belasan detik).
- Server web berisiko mengalami thread exhaustion saat terjadi lonjakan trafik bersamaan, menyebabkan request sederhana lainnya ikut tertahan dan berakhir dengan status HTTP 504 Gateway Timeout.
Solusi arsitektur yang paling teruji untuk masalah ini adalah memisahkan penanganan request dari pemrosesan logika bisnis menggunakan Arsitektur Event-Driven berbasis antrean pesan (message queue) terdistribusi dengan NestJS, BullMQ, dan Redis.
Mengapa Memilih BullMQ di Atas Redis? #
Banyak pengembang pemula mencoba membuat antrean sendiri menggunakan struktur data Redis List (LPUSH dan RPOP). Meskipun sederhana, pendekatan manual tersebut rentan terhadap kehilangan pesan (message loss) apabila proses worker mengalami crash di tengah eksekusi task.
BullMQ adalah pustaka antrean pesan NodeJS canggih berbasis Redis Streams dan skrip Lua atomik yang menyediakan fitur keandalan tingkat enterprise:
- Jaminan Pengiriman Minimal Sekali (At-Least-Once Delivery): Pesan tidak dihapus dari Redis sampai proses worker secara eksplisit mengonfirmasi bahwa tugas telah selesai dieksekusi.
- Percobaan Ulang Otomatis dengan Backoff Eksponensial: Menghindari kegagalan sementara (transient errors) pada API pihak ketiga dengan menunda pengiriman ulang secara proporsional.
- Dead-Letter Queue (DLQ): Mengisolasi tugas yang gagal berulang kali ke antrean khusus untuk diinvestigasi oleh engineer tanpa menyumbat antrean utama.
- Manajemen Konkurensi Terkontrol: Membatasi jumlah job paralel per worker untuk mencegah kehabisan memori CPU pada server database.
Arsitektur Antrean Event-Driven NestJS & BullMQ:
Browser Klien ────> [NestJS HTTP Controller]
│ (Menaruh Job ke Redis dalam 2 milidetik)
▼ Balas Segera: HTTP 202 Accepted { jobId: '123' }
┌──────────────────────────────────────────────┐
│ Redis (BullMQ Queue Cluster) │
│ - Antrean Aktif (Waiting / Active) │
│ - Antrean Tertunda (Delayed / Retry) │
│ - Antrean Gagal (Dead-Letter Queue) │
└──────────────────────────────────────────────┘
│ (Worker menarik tugas secara asynchronous)
▼
[NestJS Worker / Processor]
- Sinkronisasi Data ERP SAP
- Rendering Dokumen Faktur Pajak
- Mengirim Notifikasi WhatsApp Gateway
Mengonfigurasi Modul BullMQ pada NestJS #
Pasang pustaka yang diperlukan:
npm install @nestjs/bullmq bullmq ioredis
Definisikan konfigurasi koneksi Redis terpusat di dalam app.module.ts:
// backend/src/app.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
BullModule.forRoot({
connection: {
host: process.env.REDIS_HOST || '127.0.0.1',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: null, // Wajib disetel null untuk BullMQ
enableReadyCheck: false,
},
}),
// Registrasi antrean spesifik domain invoice
BullModule.registerQueue({
name: 'invoice-processing',
}),
],
})
export class AppModule {}
Memproduksi Job di Controller (Fast HTTP Response) #
Controller menerima payload transaksi dari klien, memvalidasi format DTO, menyisipkan task ke antrean BullMQ, dan langsung mengembalikan respons HTTP 202 Accepted kepada pengguna dalam waktu kurang dari 5 milidetik:
// backend/src/modules/invoice/invoice.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
@Controller('invoices')
export class InvoiceController {
constructor(
@InjectQueue('invoice-processing') private readonly invoiceQueue: Queue
) {}
@Post('generate')
@HttpCode(HttpStatus.ACCEPTED)
async createInvoice(@Body() dto: { orderId: string; clientEmail: string }) {
// Sisipkan job ke antrean dengan konfigurasi ketahanan (resilience)
const job = await this.invoiceQueue.add(
'process-tax-invoice',
{
orderId: dto.orderId,
clientEmail: dto.clientEmail,
queuedAt: new Date().toISOString(),
},
{
attempts: 5, // Percobaan ulang maksimal 5 kali jika gagal
backoff: {
type: 'exponential',
delay: 5000, // Mulai dari jeda 5 detik, 10s, 20s, dst
},
removeOnComplete: 1000, // Simpan histori 1.000 job sukses terakhir
removeOnFail: 5000, // Simpan histori job gagal untuk audit
jobId: `invoice-${dto.orderId}`, // Mencegah duplikasi job untuk order yang sama
}
);
return {
status: 'accepted',
message: 'Faktur sedang diproses di latar belakang',
jobId: job.id,
};
}
}
Mengonsumsi Job di Worker Processor dengan Idempotency #
Worker bertanggung jawab menjalankan komputasi berat. Sangat penting menerapkan prinsip Idempotency agar jika sebuah job dijalankan ulang akibat network glitch sesaat, sistem tidak menghasilkan dokumen ganda atau tagihan ganda ke pelanggan:
// backend/src/modules/invoice/invoice.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { Logger } from '@nestjs/common';
@Processor('invoice-processing', { concurrency: 5 }) // Menjalankan 5 job bersamaan
export class InvoiceProcessor extends WorkerHost {
private readonly logger = new Logger(InvoiceProcessor.name);
async process(job: Job<any, any, string>): Promise<any> {
this.logger.log(`Memulai pemrosesan Job ID: ${job.id} untuk Order: ${job.data.orderId}`);
switch (job.name) {
case 'process-tax-invoice': {
const { orderId, clientEmail } = job.data;
// 1. Pemeriksaan Idempotensi: Cek apakah invoice sudah terbit di database
const isAlreadyProcessed = await this.checkExistingInvoice(orderId);
if (isAlreadyProcessed) {
this.logger.warn(`Invoice untuk Order ${orderId} sudah pernah diproses. Melewati langkah.`);
return { status: 'skipped', reason: 'already_exists' };
}
// 2. Eksekusi kalkulasi pajak dan integrasi API ERP
const pdfBuffer = await this.generatePdfDocument(orderId);
await this.syncToErpSystem(orderId, pdfBuffer);
// 3. Kirim notifikasi email ke klien
await this.sendEmailNotification(clientEmail, pdfBuffer);
this.logger.log(`Selesai memproses Job ID: ${job.id}`);
return { status: 'success', orderId };
}
default:
throw new Error(`Tipe job tidak dikenali: ${job.name}`);
}
}
private async checkExistingInvoice(orderId: string): Promise<boolean> {
// Logika pengecekan status invoice pada tabel database
return false;
}
private async generatePdfDocument(orderId: string): Promise<Buffer> {
// Logika komputasi pembuatan dokumen PDF
return Buffer.from('PDF_CONTENT');
}
private async syncToErpSystem(orderId: string, pdf: Buffer): Promise<void> {
// Panggilan API eksternal menuju ERP SAP / Odoo
}
private async sendEmailNotification(email: string, pdf: Buffer): Promise<void> {
// Pengiriman email via SMTP stackmail
}
}
Praktik Terbaik Manajemen Antrean di Lingkungan Produksi #
- Pemisahan Proses Web dan Worker: Di lingkungan produksi, jalankan worker BullMQ sebagai proses PM2 atau container Kubernetes terpisah dari instance API gateway. Jika worker mengalami lonjakan pemakaian CPU saat merender dokumen berat, server API publik tetap responsif melayani pengunjung.
- Monitoring Kesehatan Antrean Menggunakan Bull-Board: Integrasikan dashboard antarmuka visual
@bull-board/nestjsdi rute admin privat untuk memantau status antrean (Waiting, Active, Completed, Failed) secara real-time. - Penyetelan Konkurensi Sesuai Batas Database: Jangan menyetel parameter
concurrencyterlalu tinggi tanpa memeriksa kapasitas connection pool database PostgreSQL. Batas konkurensi 5 hingga 10 per core prosesor merupakan acuan yang aman. - Alarm Otomatis untuk Dead-Letter Queue: Pasang listener event kegagalan (
@OnWorkerEvent('failed')) yang secara otomatis mengirimkan notifikasi peringatan ke saluran Discord atau grup WhatsApp engineering jika suatu tugas gagal setelah batas maksimum percobaan ulang.
