Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/queue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"bullmq": "^4.14.0",
"express": "^4.18.2",
"jsdom": "^26.1.0",
"liquidjs": "^10.11.1",
"liquidjs": "10.26.0",
"mongodb": "^6.21.0",
"mongoose": "^8.22.1",
"nodemailer": "^6.9.2",
Expand Down
85 changes: 85 additions & 0 deletions apps/queue/src/bullmq.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Job, Worker, WorkerOptions } from "bullmq";
import redis from "./redis";
import { logger } from "./logger";
import {
captureError,
captureEvent,
getDomainId,
} from "./observability/posthog";

export const workerOptions: WorkerOptions = {
connection: redis,
lockDuration: 5 * 60 * 1000,
stalledInterval: 30 * 1000,
maxStalledCount: 2,
};

export function registerWorkerEvents(
worker: Pick<Worker, "on">,
queueName: string,
) {
worker.on("ready", () => {
captureEvent({
event: "queue_worker_ready",
source: `worker.${queueName}.ready`,
domainId: getDomainId(),
properties: {
queue_name: queueName,
},
});
});

worker.on("closed", () => {
captureEvent({
event: "queue_worker_closed",
source: `worker.${queueName}.closed`,
domainId: getDomainId(),
properties: {
queue_name: queueName,
},
});
});

worker.on("failed", (job: Job | undefined, err: Error) => {
const domainId = getDomainId(job?.data?.domainId || job?.data?.domain);
logger.error(err);
captureError({
error: err,
source: `worker.${queueName}.failed`,
domainId,
context: {
queue_name: queueName,
job_id: job?.id ? String(job.id) : undefined,
job_name: job?.name,
failed_reason: job?.failedReason,
attempts_made: job?.attemptsMade,
},
});
});

worker.on("stalled", (jobId: string) => {
const err = new Error(`BullMQ job stalled in ${queueName}: ${jobId}`);
logger.error(err);
captureError({
error: err,
source: `worker.${queueName}.stalled`,
domainId: getDomainId(),
context: {
queue_name: queueName,
job_id: jobId,
},
});
});

worker.on("error", (err: Error) => {
logger.error(err);
captureError({
error: err,
source: `worker.${queueName}.error`,
domainId: getDomainId(),
context: {
queue_name: queueName,
},
});
});
}
13 changes: 9 additions & 4 deletions apps/queue/src/domain/process-ongoing-sequences/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { logger } from "../../logger";
import { getDueOngoingSequences } from "../queries";
import { Worker } from "bullmq";
import redis from "../../redis";
import sequenceQueue from "../sequence-queue";
import { processOngoingSequence } from "./process-ongoing-sequence";
import { captureError, getDomainId } from "../../observability/posthog";
import { registerWorkerEvents, workerOptions } from "../../bullmq";

interface SequenceWorkerData {
ongoingSequenceId: string;
domainId?: string;
}

if (process.env.NODE_ENV !== "test") {
new Worker(
export function startSequenceWorker() {
const worker = new Worker(
"sequence",
async (job) => {
const payload = job.data as SequenceWorkerData | string;
Expand All @@ -38,10 +38,15 @@ if (process.env.NODE_ENV !== "test") {
ongoing_sequence_id: String(ongoingSequenceId),
},
});
throw err;
}
},
{ connection: redis },
workerOptions,
);

registerWorkerEvents(worker, "sequence");

return worker;
}

export async function processOngoingSequences(): Promise<void> {
Expand Down
69 changes: 37 additions & 32 deletions apps/queue/src/domain/worker.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,44 @@
import { Worker } from "bullmq";
import redis from "../redis";
import { logger } from "../logger";
import { captureError, getDomainId } from "../observability/posthog";
import { sendMail } from "../mail";
import { registerWorkerEvents, workerOptions } from "../bullmq";

const worker = new Worker(
"mail",
async (job) => {
const { to, from, subject, body, headers, domainId } = job.data;
export function startMailWorker() {
const worker = new Worker(
"mail",
async (job) => {
const { to, from, subject, body, headers, domainId } = job.data;

try {
await sendMail({
from,
to,
subject,
html: body,
headers,
});
} catch (err: any) {
logger.error(err);
captureError({
error: err,
source: "worker.mail",
domainId: getDomainId(domainId),
context: {
queue_name: "mail",
job_id: String(job.id),
error_code: err?.code,
response_code: err?.responseCode,
command: err?.command,
},
});
}
},
{ connection: redis },
);
try {
await sendMail({
from,
to,
subject,
html: body,
headers,
});
} catch (err: any) {
logger.error(err);
captureError({
error: err,
source: "worker.mail",
domainId: getDomainId(domainId),
context: {
queue_name: "mail",
job_id: String(job.id),
error_code: err?.code,
response_code: err?.responseCode,
command: err?.command,
},
});
throw err;
}
},
workerOptions,
);

export default worker;
registerWorkerEvents(worker, "mail");

return worker;
}
40 changes: 35 additions & 5 deletions apps/queue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import express from "express";
import jobRoutes from "./job/routes";
import sseRoutes from "./sse/routes";

// start workers
import "./domain/worker";
import "./notifications/worker/notification";
import "./notifications/worker/dispatch-notification";

// start loops
import { startEmailAutomation } from "./start-email-automation";
import { startSequenceWorker } from "./domain/process-ongoing-sequences";
import { startMailWorker } from "./domain/worker";
import { startNotificationWorker } from "./notifications/worker/notification";
import { startDispatchNotificationWorker } from "./notifications/worker/dispatch-notification";
import { verifyJWTMiddleware } from "./middlewares/verify-jwt";
import {
captureError,
Expand All @@ -18,6 +17,37 @@ import {
import { initPosthogLogs } from "./observability/logs";
import { logger } from "./logger";

const workers =
process.env.NODE_ENV === "test"
? []
: [
startMailWorker(),
startNotificationWorker(),
startDispatchNotificationWorker(),
startSequenceWorker(),
];

async function closeWorkers() {
await Promise.allSettled(workers.map((worker) => worker.close()));
}

for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => {
closeWorkers()
.catch((err) => {
logger.error(err);
captureError({
error: err,
source: "service.shutdown",
domainId: getDomainId(),
});
})
.finally(() => {
process.kill(process.pid, signal);
});
});
}

const app = express();
app.use(express.json());

Expand Down
54 changes: 29 additions & 25 deletions apps/queue/src/notifications/worker/dispatch-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
Constants,
NotificationChannel as NotificationChannelType,
} from "@courselit/common-models";
import redis from "../../redis";
import { logger } from "../../logger";
import UserModel from "../../domain/model/user";
import DomainModel from "../../domain/model/domain";
Expand All @@ -18,6 +17,7 @@ import {
NotificationChannel,
} from "../services/channels/types";
import { captureError, getDomainId } from "../../observability/posthog";
import { registerWorkerEvents, workerOptions } from "../../bullmq";

interface DispatchNotificationJob {
domain: string | mongoose.Types.ObjectId;
Expand All @@ -33,31 +33,35 @@ const channelRegistry: Record<string, NotificationChannel> = {
[Constants.NotificationChannel.EMAIL]: new EmailChannel(),
};

const worker = new Worker(
"dispatch-notification",
async (job) => {
const payload = job.data as DispatchNotificationJob;
try {
await processDispatchNotificationJob(payload);
} catch (err: any) {
logger.error(err);
captureError({
error: err,
source: "worker.dispatch_notification",
domainId: getDomainId(payload?.domain),
context: {
queue_name: "dispatch-notification",
job_id: String(job.id),
activity_type: payload?.activityType,
},
});
throw err;
}
},
{ connection: redis },
);
export function startDispatchNotificationWorker() {
const worker = new Worker(
"dispatch-notification",
async (job) => {
const payload = job.data as DispatchNotificationJob;
try {
await processDispatchNotificationJob(payload);
} catch (err: any) {
logger.error(err);
captureError({
error: err,
source: "worker.dispatch_notification",
domainId: getDomainId(payload?.domain),
context: {
queue_name: "dispatch-notification",
job_id: String(job.id),
activity_type: payload?.activityType,
},
});
throw err;
}
},
workerOptions,
);

export default worker;
registerWorkerEvents(worker, "dispatch-notification");

return worker;
}

async function processDispatchNotificationJob(job: DispatchNotificationJob) {
if (!Object.values(Constants.ActivityType).includes(job.activityType)) {
Expand Down
Loading
Loading