Commit b68ff0d7 authored by ThinhNC's avatar ThinhNC

feat: implement background queue system using BullMQ for cron, crawl, and webhook processing

parent adc75aa5
services:
- type: web
name: data-crawler-be
env: node
plan: free
region: singapore
buildCommand: pnpm install --frozen-lockfile && pnpm prisma:generate && pnpm build
startCommand: pnpm start
healthCheckPath: /health/liveness
envVars:
- key: NODE_ENV
value: production
- key: TRUST_PROXY
value: "true"
- key: START_CRAWL_WORKER
value: "true"
- key: DATABASE_URL
sync: false
- key: JWT_ACCESS_SECRET
generateValue: true
- key: JWT_REFRESH_SECRET
generateValue: true
- key: REDIS_ENABLED
value: "true"
- key: REDIS_URL
sync: false
- key: STORAGE_DRIVER
value: s3
- key: S3_ENDPOINT
sync: false
- key: S3_REGION
value: ap-southeast-1
- key: S3_BUCKET
value: data-crawler-exports
- key: S3_ACCESS_KEY_ID
sync: false
- key: S3_SECRET_ACCESS_KEY
sync: false
- key: S3_FORCE_PATH_STYLE
value: "true"
- key: FRONTEND_URL
sync: false
import Redis from "ioredis"; import Redis from "ioredis";
import { envConfig } from "../../config/env.config"; import { envConfig } from "../../config/env.config";
import { getRedisClientOptions } from "./redis-connection";
let generalClient: Redis | null = null; let generalClient: Redis | null = null;
...@@ -25,15 +26,10 @@ export function getRedisClient(): Redis | null { ...@@ -25,15 +26,10 @@ export function getRedisClient(): Redis | null {
if (!generalClient) { if (!generalClient) {
try { try {
generalClient = new Redis({ const conn = getRedisClientOptions();
host: envConfig.redis.host, generalClient = conn.url
port: envConfig.redis.port, ? new Redis(conn.url, conn.options)
maxRetriesPerRequest: 1, : new Redis(conn.options);
lazyConnect: true,
connectTimeout: 2000,
retryStrategy: () => null,
enableOfflineQueue: false,
});
generalClient.on("error", () => { generalClient.on("error", () => {
// Suppress unhandled crash logs on reconnect/timeout // Suppress unhandled crash logs on reconnect/timeout
......
import { envConfig } from "../../config/env.config";
import type { ConnectionOptions } from "bullmq";
import type { RedisOptions } from "ioredis";
/**
* Cung cấp tùy chọn kết nối Redis cho ioredis (hỗ trợ cả REDIS_URL và host/port/password)
*/
export function getRedisClientOptions(customOpts: RedisOptions = {}): {
url?: string;
options: RedisOptions;
} {
const commonOpts: RedisOptions = {
maxRetriesPerRequest: 1,
lazyConnect: true,
connectTimeout: 5000,
retryStrategy: () => null,
enableOfflineQueue: false,
...customOpts,
};
if (envConfig.redis.url) {
return {
url: envConfig.redis.url,
options: commonOpts,
};
}
return {
options: {
host: envConfig.redis.host,
port: envConfig.redis.port,
password: envConfig.redis.password,
...commonOpts,
},
};
}
/**
* Cung cấp ConnectionOptions cho BullMQ Queues và Workers (hỗ trợ cả REDIS_URL và host/port/password)
*/
export function getBullMQConnection(
extraOpts: Record<string, unknown> = {},
): ConnectionOptions {
if (envConfig.redis.url) {
return {
url: envConfig.redis.url,
...extraOpts,
};
}
return {
host: envConfig.redis.host,
port: envConfig.redis.port,
password: envConfig.redis.password,
...extraOpts,
};
}
import Redis from "ioredis"; import Redis from "ioredis";
import { envConfig } from "../../config/env.config"; import { envConfig } from "../../config/env.config";
import { getRedisClientOptions } from "./redis-connection";
let publisherClient: Redis | null = null; let publisherClient: Redis | null = null;
let subscriberClient: Redis | null = null; let subscriberClient: Redis | null = null;
...@@ -9,15 +10,10 @@ export function getRedisPublisher(): Redis | null { ...@@ -9,15 +10,10 @@ export function getRedisPublisher(): Redis | null {
if (!publisherClient) { if (!publisherClient) {
try { try {
publisherClient = new Redis({ const conn = getRedisClientOptions();
host: envConfig.redis.host, publisherClient = conn.url
port: envConfig.redis.port, ? new Redis(conn.url, conn.options)
maxRetriesPerRequest: 1, : new Redis(conn.options);
lazyConnect: true,
connectTimeout: 2000,
retryStrategy: () => null,
enableOfflineQueue: false,
});
publisherClient.on("error", () => { publisherClient.on("error", () => {
// Suppress unhandled redis error crashes // Suppress unhandled redis error crashes
...@@ -35,15 +31,10 @@ export function getRedisSubscriber(): Redis | null { ...@@ -35,15 +31,10 @@ export function getRedisSubscriber(): Redis | null {
if (!subscriberClient) { if (!subscriberClient) {
try { try {
subscriberClient = new Redis({ const conn = getRedisClientOptions();
host: envConfig.redis.host, subscriberClient = conn.url
port: envConfig.redis.port, ? new Redis(conn.url, conn.options)
maxRetriesPerRequest: 1, : new Redis(conn.options);
lazyConnect: true,
connectTimeout: 2000,
retryStrategy: () => null,
enableOfflineQueue: false,
});
subscriberClient.on("error", () => { subscriberClient.on("error", () => {
// Suppress unhandled redis error crashes // Suppress unhandled redis error crashes
......
...@@ -52,9 +52,12 @@ export const envConfig = { ...@@ -52,9 +52,12 @@ export const envConfig = {
), ),
}, },
redis: { redis: {
url: process.env.REDIS_URL || "",
host: process.env.REDIS_HOST || "127.0.0.1", host: process.env.REDIS_HOST || "127.0.0.1",
port: parseInt(process.env.REDIS_PORT || "6379", 10), port: parseInt(process.env.REDIS_PORT || "6379", 10),
enabled: process.env.REDIS_ENABLED === "true", password: process.env.REDIS_PASSWORD || undefined,
enabled:
process.env.REDIS_ENABLED === "true" || Boolean(process.env.REDIS_URL),
}, },
rateLimit: { rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || "900000", 10), windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || "900000", 10),
......
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { envConfig } from "../config/env.config"; import { envConfig } from "../config/env.config";
import { getBullMQConnection } from "../common/redis/redis-connection";
export const crawlQueue = envConfig.redis.enabled export const crawlQueue = envConfig.redis.enabled
? new Queue("crawl-jobs", { ? new Queue("crawl-jobs", {
connection: { connection: getBullMQConnection({
host: envConfig.redis.host,
port: envConfig.redis.port,
enableOfflineQueue: false, enableOfflineQueue: false,
lazyConnect: true, lazyConnect: true,
}, }),
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
backoff: { type: "exponential", delay: 10000 }, backoff: { type: "exponential", delay: 10000 },
......
import "dotenv/config"; import "dotenv/config";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import { envConfig } from "../config/env.config"; import { envConfig } from "../config/env.config";
import { getBullMQConnection } from "../common/redis/redis-connection";
import { processCrawlJob, withTimeout } from "./crawl.worker.processor"; import { processCrawlJob, withTimeout } from "./crawl.worker.processor";
if (!envConfig.redis.enabled) { export let crawlWorker: Worker | null = null;
if (envConfig.redis.enabled) {
crawlWorker = new Worker(
"crawl-jobs",
(job) =>
withTimeout(
processCrawlJob(job),
envConfig.worker.jobTimeoutMs,
job.data.jobId,
),
{
connection: getBullMQConnection({
maxRetriesPerRequest: null,
}),
concurrency: envConfig.worker.concurrency,
maxStalledCount: envConfig.worker.maxStalledCount,
},
);
crawlWorker.on("failed", (job, err) => {
console.error(`[Worker] Job ${job?.id} failed: ${err.message}`);
});
console.log("[Worker] Crawl worker started");
} else {
console.log( console.log(
"[Worker] REDIS_ENABLED is not set to true. Worker will not start.", "[Worker] REDIS_ENABLED is not set to true. Worker will not start.",
); );
process.exit(0);
} }
const worker = new Worker(
"crawl-jobs",
(job) =>
withTimeout(
processCrawlJob(job),
envConfig.worker.jobTimeoutMs,
job.data.jobId,
),
{
connection: {
host: envConfig.redis.host,
port: envConfig.redis.port,
maxRetriesPerRequest: null,
},
concurrency: envConfig.worker.concurrency,
maxStalledCount: envConfig.worker.maxStalledCount,
},
);
worker.on("failed", (job, err) => {
console.error(`[Worker] Job ${job?.id} failed: ${err.message}`);
});
async function gracefulShutdown(signal: string) { async function gracefulShutdown(signal: string) {
console.log(`[Worker] Received ${signal}, closing worker gracefully...`); if (crawlWorker) {
await worker.close(); console.log(`[Worker] Received ${signal}, closing worker gracefully...`);
console.log("[Worker] Worker closed"); await crawlWorker.close();
console.log("[Worker] Worker closed");
}
process.exit(0); process.exit(0);
} }
process.on("SIGTERM", async () => gracefulShutdown("SIGTERM")); process.on("SIGTERM", async () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", async () => gracefulShutdown("SIGINT")); process.on("SIGINT", async () => gracefulShutdown("SIGINT"));
console.log("[Worker] Crawl worker started");
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { envConfig } from "../config/env.config"; import { envConfig } from "../config/env.config";
import { getBullMQConnection } from "../common/redis/redis-connection";
import { import {
CRON_QUEUE_NAME, CRON_QUEUE_NAME,
CronJobName, CronJobName,
...@@ -13,12 +14,10 @@ export class CronQueueService { ...@@ -13,12 +14,10 @@ export class CronQueueService {
constructor() { constructor() {
if (envConfig.redis.enabled) { if (envConfig.redis.enabled) {
this.queue = new Queue(CRON_QUEUE_NAME, { this.queue = new Queue(CRON_QUEUE_NAME, {
connection: { connection: getBullMQConnection({
host: envConfig.redis.host,
port: envConfig.redis.port,
enableOfflineQueue: false, enableOfflineQueue: false,
lazyConnect: true, lazyConnect: true,
}, }),
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
backoff: { type: "exponential", delay: 5000 }, backoff: { type: "exponential", delay: 5000 },
......
import "dotenv/config"; import "dotenv/config";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import { envConfig } from "../config/env.config"; import { envConfig } from "../config/env.config";
import { getBullMQConnection } from "../common/redis/redis-connection";
import { cronService } from "../modules/cron/cron.service"; import { cronService } from "../modules/cron/cron.service";
import { import {
CRON_QUEUE_NAME, CRON_QUEUE_NAME,
...@@ -55,11 +56,9 @@ export const cronWorker = new Worker( ...@@ -55,11 +56,9 @@ export const cronWorker = new Worker(
} }
}, },
{ {
connection: { connection: getBullMQConnection({
host: envConfig.redis.host,
port: envConfig.redis.port,
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
}, }),
concurrency: 2, concurrency: 2,
}, },
); );
......
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { envConfig } from "../config/env.config"; import { envConfig } from "../config/env.config";
import { getBullMQConnection } from "../common/redis/redis-connection";
export const webhookQueue = envConfig.redis.enabled export const webhookQueue = envConfig.redis.enabled
? new Queue(envConfig.webhook.queueName, { ? new Queue(envConfig.webhook.queueName, {
connection: { connection: getBullMQConnection({
host: envConfig.redis.host,
port: envConfig.redis.port,
enableOfflineQueue: false, enableOfflineQueue: false,
lazyConnect: true, lazyConnect: true,
}, }),
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
backoff: { type: "exponential", delay: 5000 }, backoff: { type: "exponential", delay: 5000 },
......
import "dotenv/config"; import "dotenv/config";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import { envConfig } from "../config/env.config"; import { envConfig } from "../config/env.config";
import { getBullMQConnection } from "../common/redis/redis-connection";
import { WebhookDeliveryService } from "../modules/webhooks/webhook-delivery.service"; import { WebhookDeliveryService } from "../modules/webhooks/webhook-delivery.service";
import { getErrorMessage } from "../common/helpers/error-mapping.helper"; import { getErrorMessage } from "../common/helpers/error-mapping.helper";
...@@ -49,11 +50,9 @@ export const webhookWorker = new Worker( ...@@ -49,11 +50,9 @@ export const webhookWorker = new Worker(
} }
}, },
{ {
connection: { connection: getBullMQConnection({
host: envConfig.redis.host,
port: envConfig.redis.port,
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
}, }),
concurrency: 5, concurrency: 5,
}, },
); );
......
...@@ -70,6 +70,11 @@ async function bootstrap() { ...@@ -70,6 +70,11 @@ async function bootstrap() {
await import("./queues/cron.worker"); await import("./queues/cron.worker");
console.log("[Server] Cron worker initialized in background."); console.log("[Server] Cron worker initialized in background.");
if (process.env.START_CRAWL_WORKER !== "false") {
await import("./queues/crawl.worker");
console.log("[Server] Crawl worker initialized in background.");
}
} }
app.listen(envConfig.port, () => { app.listen(envConfig.port, () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment