1
26import { setTimeout as delay } from "node:timers/promises";
27import { Queue, Worker } from "bullmq";
28import { initOtel, shutdownOtel, m, renderPrometheus } from "@factory/observability";
29import { loadEnv } from "./config.js";
30import { getLogger } from "./logger.js";
31import { getRedis, closeRedis, createRedis } from "./lib/redis.js";
32import { createHealthServer } from "./health/server.js";
33import { connectPrisma, closePrisma } from "./lib/prisma.js";
34import { S3Client } from "./lib/s3.js";
35import { SearchClient } from "@factory/search";
36import { QUEUES, enqueue } from "./queues.js";
37import { createFilesWorker } from "./processors/files/processor.js";
38import { assertScannerConfigured } from "./processors/files/steps/scan.js";
39import { cleanupTempFiles } from "./processors/files/temp.js";
40import {
41 createSearchIndexWorker,
42 createSearchIndexConsumer,
43 ensureContentIndex,
44 recordSearchDlq
45} from "./processors/search-index/index.js";
46import {
47 createWebhooksWorker,
48 createWebhooksConsumer,
49 handleWebhookFinalFailure
50} from "./processors/webhooks.processor.js";
51import {
52 createAutomationsWorker,
53 createAutomationsConsumer
54} from "./processors/automations.processor.js";
55import { reconcileScheduleAutomations } from "./processors/automations/schedule.js";
56import { runWatchdog } from "./processors/automations/watchdog.js";
57import { createEmailWorker } from "./processors/email.processor.js";
58import { createNotificationEmailConsumer } from "./processors/email-decision.processor.js";
59import { createAiWorker } from "./processors/ai.processor.js";
60import {
61 createMaintenanceWorker,
62 MAINTENANCE_REPEATABLES
63} from "./processors/maintenance.processor.js";
64
65const log = getLogger("main");
66
67const SHUTDOWN_CAP_MS = 30_000;
68const HEARTBEAT_INTERVAL_MS = 30_000;
69const HEARTBEAT_TTL_SEC = 90;
70
71async function main(): Promise<void> {
72 const env = loadEnv();
73 const started = Date.now();
74
75
76
77
78 const otelEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
79 await initOtel({
80 serviceName: "factory-worker",
81 environment: env.NODE_ENV,
82 ...(process.env.OTEL_ENABLED === "true" && otelEndpoint ? { endpoint: otelEndpoint } : {})
83 });
84 log.info(
85 {
86 workerId: env.WORKER_ID,
87 concurrency: env.WORKER_CONCURRENCY,
88 env: env.NODE_ENV,
89 smtp: env.SMTP_HOST ? "configured" : "not configured (email no-op)",
90 clamav: env.CLAMD_SOCKET
91 ? `unix ${env.CLAMD_SOCKET}`
92 : env.CLAMAV_HOST
93 ? `tcp ${env.CLAMAV_HOST}:${env.CLAMAV_PORT}`
94 : "not configured (scans skipped, dev mode)",
95 ai: env.AI_API_KEY ? "configured" : "not configured (ai no-op)",
96 search: env.SEARCH_URL
97 },
98 "factory worker starting"
99 );
100
101
102 const redis = getRedis(env.REDIS_URL);
103 const prisma = await connectPrisma();
104 const s3 = new S3Client({
105 endpoint: env.S3_ENDPOINT,
106 region: env.S3_REGION,
107
108
109
110
111
112
113
114 bucket: env.S3_BUCKET_FILES || env.S3_BUCKET,
115 accessKey: env.S3_ACCESS_KEY,
116 secretKey: env.S3_SECRET_KEY,
117 publicBase: env.S3_PUBLIC_BASE
118 });
119
120
121 const s3Thumbs = new S3Client({
122 endpoint: env.S3_ENDPOINT,
123 region: env.S3_REGION,
124 bucket: env.S3_BUCKET_THUMBS,
125 accessKey: env.S3_ACCESS_KEY,
126 secretKey: env.S3_SECRET_KEY
127 });
128
129
130 assertScannerConfigured(env);
131
132
133
134 const search = new SearchClient({
135 baseUrl: env.SEARCH_URL,
136 ...(env.SEARCH_PASSWORD ? { auth: { username: env.SEARCH_USERNAME, password: env.SEARCH_PASSWORD } } : {})
137 });
138 const searchOps = {
139 search,
140 prisma,
141 connection: redis,
142 env,
143
144
145
146
147 enqueueEmbed: (job: { workspaceId: string; docType: "page" | "record"; refId: string }) =>
148 enqueue(redis, QUEUES.ai, { type: "embed:chunks", workspaceId: job.workspaceId, docType: job.docType, refId: job.refId } as Record<string, unknown>)
149 };
150 await ensureContentIndex(searchOps).catch((err) => {
151 log.warn(
152 { err: (err as Error).message },
153 "OpenSearch unavailable at startup — index will be created lazily by the first (re)index; continuing"
154 );
155 });
156
157
158 const workers: Worker[] = [];
159 const push = (w: Worker): void => {
160 w.on("failed", (job, err) => {
161 log.error(
162 { queue: w.name, jobId: job?.id, err: err.message },
163 "job failed (BullMQ will retry per job options)"
164 );
165 });
166 workers.push(w);
167 };
168
169 push(
170 createFilesWorker({
171 connection: redis,
172 prisma,
173 env,
174 s3,
175 s3Thumbs,
176
177
178
179 concurrency: env.FILES_WORKER_CONCURRENCY
180 })
181 );
182 const searchIndexWorker = createSearchIndexWorker({
183 connection: redis,
184 ops: searchOps,
185 concurrency: env.WORKER_CONCURRENCY
186 });
187 searchIndexWorker.on("failed", (job, err) => {
188 if (job && job.attemptsMade >= 7) {
189
190 void recordSearchDlq(redis, job, err);
191 }
192 });
193 push(searchIndexWorker);
194 const webhooksWorker = createWebhooksWorker({
195 connection: redis,
196 prisma,
197 env,
198 concurrency: env.WORKER_CONCURRENCY
199 });
200 webhooksWorker.on("failed", (job, err) => {
201
202
203
204
205 if (job) void handleWebhookFinalFailure(prisma, redis, env)(job, err);
206 });
207 push(webhooksWorker);
208
209
210
211 const automationsWorker = createAutomationsWorker({
212 connection: redis,
213 prisma,
214 env,
215 concurrency: env.AUTOMATIONS_WORKER_CONCURRENCY
216 });
217 push(automationsWorker);
218 push(
219 createEmailWorker({
220 connection: redis,
221 prisma,
222 env,
223 concurrency: env.WORKER_CONCURRENCY
224 })
225 );
226 push(
227 createAiWorker({
228 connection: redis,
229 prisma,
230 env,
231 search,
232 concurrency: env.WORKER_CONCURRENCY
233 })
234 );
235 const maintenanceWorker = createMaintenanceWorker({
236 connection: redis,
237 prisma,
238 env,
239 concurrency: Math.max(1, Math.min(2, env.WORKER_CONCURRENCY))
240 });
241 push(maintenanceWorker);
242 log.info({ count: workers.length }, "bullmq workers created");
243
244
245
246
247
248
249 const queueDepthTick = async (): Promise<void> => {
250 for (const name of Object.values(QUEUES)) {
251 const q = new Queue(name, { connection: redis });
252 try {
253 const c = await q.getJobCounts("waiting", "delayed");
254 m.queue.setDepth(name, (c.waiting ?? 0) + (c.delayed ?? 0));
255 } catch (err) {
256 log.debug({ queue: name, err: (err as Error).message }, "queue depth probe failed");
257 } finally {
258 await q.close();
259 }
260 }
261 };
262 void queueDepthTick();
263 const queueDepthTimer = setInterval(() => void queueDepthTick(), 15_000);
264 queueDepthTimer.unref();
265
266
267
268
269
270
271
272
273
274
275
276 const searchStreamRedis = createRedis(env.REDIS_URL);
277 const webhooksStreamRedis = createRedis(env.REDIS_URL);
278 const automationsStreamRedis = createRedis(env.REDIS_URL);
279 const emailStreamRedis = createRedis(env.REDIS_URL);
280 const consumers = [
281 createSearchIndexConsumer({
282 redis: searchStreamRedis,
283 ops: searchOps,
284 consumerId: `${env.WORKER_ID}-search`
285 }),
286 createWebhooksConsumer({
287 redis: webhooksStreamRedis,
288 prisma,
289 consumerId: `${env.WORKER_ID}-webhooks`
290 }),
291 createAutomationsConsumer({
292 redis: automationsStreamRedis,
293 prisma,
294 consumerId: `${env.WORKER_ID}-automations`
295 }),
296
297
298
299
300
301 createNotificationEmailConsumer({
302 redis: emailStreamRedis,
303 connection: redis,
304 prisma,
305 env,
306 consumerId: `${env.WORKER_ID}-email`
307 })
308 ];
309 for (const c of consumers) await c.start();
310 log.info({ count: consumers.length }, "stream consumers started");
311
312
313 const mq = new Queue(QUEUES.maintenance, { connection: redis });
314 for (const r of MAINTENANCE_REPEATABLES) {
315 await mq.add(r.id, r.data as Record<string, unknown>, {
316 repeat: { pattern: r.pattern },
317 jobId: r.id,
318 removeOnComplete: true,
319 removeOnFail: false
320 });
321 }
322 await mq.close();
323 log.info({ jobs: MAINTENANCE_REPEATABLES.map((r) => r.id) }, "repeatable maintenance jobs registered");
324
325
326
327
328
329
330
331 const aiQ = new Queue(QUEUES.ai, { connection: redis });
332 await aiQ.add("ai", { type: "usage.rollup" }, {
333 repeat: { pattern: "15 2 * * *" },
334 jobId: "ai:usage-rollup",
335 removeOnComplete: true,
336 removeOnFail: false
337 });
338 await aiQ.close();
339 log.info({ job: "ai:usage-rollup" }, "repeatable ai usage rollup registered");
340
341
342
343
344
345
346 const aiReembedQ = new Queue(QUEUES.ai, { connection: redis });
347 await aiReembedQ.add("ai", { type: "embed:reembed" }, {
348 repeat: { pattern: "30 3 * * *" },
349 jobId: "ai:reembed",
350 removeOnComplete: true,
351 removeOnFail: false
352 });
353 await aiReembedQ.close();
354 log.info({ job: "ai:reembed" }, "repeatable ai nightly re-embed registered");
355
356
357 const scheduleReconcile = async (): Promise<void> => {
358 try {
359 await reconcileScheduleAutomations(redis, prisma);
360 } catch (err) {
361 log.warn({ err: (err as Error).message }, "schedule reconcile failed");
362 }
363 };
364 await scheduleReconcile();
365 const scheduleTimer = setInterval(scheduleReconcile, 5 * 60_000);
366 scheduleTimer.unref();
367
368
369
370
371
372
373 const watchdogTick = async (): Promise<void> => {
374 try {
375 await runWatchdog(prisma, redis, env);
376 } catch (err) {
377 log.warn({ err: (err as Error).message }, "watchdog pass failed");
378 }
379 };
380 const watchdogTimer = setInterval(watchdogTick, 5 * 60_000);
381 watchdogTimer.unref();
382
383
384
385
386
387 const tempCleanup = async (): Promise<void> => {
388 try {
389 await cleanupTempFiles(env);
390 } catch (err) {
391 log.warn({ err: (err as Error).message }, "temp cleanup cron failed");
392 }
393 };
394 await tempCleanup();
395 const tempTimer = setInterval(tempCleanup, 5 * 60_000);
396 tempTimer.unref();
397
398
399 const heartbeat = async (): Promise<void> => {
400 try {
401 await redis.set(
402 `worker:heartbeat:${env.WORKER_ID}`,
403 JSON.stringify({ at: new Date().toISOString(), uptimeMs: Date.now() - started }),
404 "EX",
405 HEARTBEAT_TTL_SEC
406 );
407 } catch (err) {
408 log.warn({ err: (err as Error).message }, "heartbeat write failed");
409 }
410 };
411 await heartbeat();
412 const heartbeatTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS);
413 heartbeatTimer.unref();
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434 const probeSearchIndexLag = async (): Promise<{
435 pending: number;
436 lagEntries: number;
437 lagSeconds: number;
438 }> => {
439 const STREAM = "factory:events";
440 const GROUP = "worker-search-index";
441 const [pendingInfo, groups] = await Promise.all([
442 (redis.xpending(STREAM, GROUP) as unknown as Promise<{ count: number }>).catch(() => ({ count: 0 })),
443 (redis.xinfo("GROUPS", STREAM) as Promise<{ name: string; "last-delivered-id": string }[]>)
444 .catch(() => [] as { name: string; "last-delivered-id": string }[])
445 ]);
446 const pending = pendingInfo?.count ?? 0;
447 let lagEntries = 0;
448 const groupInfo = groups.find((g) => g.name === GROUP);
449 let tailId: string | null = null;
450 if (groupInfo) {
451
452
453
454 const lastId = groupInfo["last-delivered-id"] ?? "0-0";
455 const tail = await redis
456 .xrange(STREAM, `<${lastId}`, "+", "COUNT", 100_000)
457 .catch((): string[][] => []);
458 lagEntries = tail.length;
459
460
461 tailId = tail[0]?.[0] ?? null;
462 }
463
464
465
466 const nowMs = Date.now();
467 let lagSeconds = 0;
468 if (tailId) {
469 const tailMs = Number(tailId.split("-")[0]);
470 if (Number.isFinite(tailMs) && tailMs > 0) lagSeconds = Math.max(0, (nowMs - tailMs) / 1000);
471 }
472 try {
473 const entries = await redis.xpending(STREAM, GROUP, "-", "+", 1);
474 const first = (Array.isArray(entries) ? entries[0] : undefined) as
475 | { "time-delivered-in-ms"?: number }
476 | undefined;
477 const deliveredMs = first?.["time-delivered-in-ms"];
478 if (typeof deliveredMs === "number" && deliveredMs > 0) {
479 lagSeconds = Math.max(lagSeconds, (nowMs - deliveredMs) / 1000);
480 }
481 } catch {
482
483
484 }
485 return { pending, lagEntries, lagSeconds };
486 };
487
488 const searchIndexLag = async (): Promise<{ pending: number; lagEntries: number }> => {
489 const r = await probeSearchIndexLag();
490 return { pending: r.pending, lagEntries: r.lagEntries };
491 };
492
493 const healthServer = createHealthServer({
494 pingRedis: async () => (await redis.ping()) === "PONG",
495 checkQueue: async () => (await redis.ping()) === "PONG",
496 checkSearchIndexLag: searchIndexLag,
497
498
499 metricsBody: renderPrometheus
500 });
501 await new Promise<void>((resolve, reject) => {
502 healthServer.once("error", reject);
503 healthServer.listen(env.HEALTH_PORT, () => {
504 healthServer.removeListener("error", reject);
505 resolve();
506 });
507 });
508 log.info({ port: env.HEALTH_PORT }, "worker health server listening");
509
510 log.info({ ms: Date.now() - started }, "factory worker ready");
511
512
513
514
515
516 const searchLagTick = async (): Promise<void> => {
517 try {
518 const { lagSeconds } = await probeSearchIndexLag();
519 m.search.lag(lagSeconds);
520 } catch (err) {
521 log.debug({ err: (err as Error).message }, "search-index lag probe failed");
522 }
523 };
524 void searchLagTick();
525 const searchLagTimer = setInterval(() => void searchLagTick(), 30_000);
526 searchLagTimer.unref();
527
528
529 let shuttingDown = false;
530 const shutdown = async (signal: string): Promise<void> => {
531 if (shuttingDown) return;
532 shuttingDown = true;
533 log.info({ signal }, "shutting down (30s cap)");
534 const cap = delay(SHUTDOWN_CAP_MS).then(() => {
535 log.error("shutdown cap reached — forcing exit");
536 process.exit(1);
537 });
538 try {
539 clearInterval(heartbeatTimer);
540 clearInterval(scheduleTimer);
541 clearInterval(tempTimer);
542 clearInterval(queueDepthTimer);
543 clearInterval(searchLagTimer);
544
545 await new Promise<void>((resolve) => {
546 healthServer.close(() => resolve());
547 healthServer.closeAllConnections?.();
548 });
549
550 await Promise.allSettled(workers.map((w) => w.close()));
551 for (const c of consumers) {
552 try {
553 await c.stop();
554 } catch (err) {
555 log.warn({ err: (err as Error).message }, "consumer stop error");
556 }
557 }
558
559
560 await Promise.allSettled(
561 [searchStreamRedis, webhooksStreamRedis, automationsStreamRedis, emailStreamRedis].map(
562 (r) => r.quit()
563 )
564 );
565
566
567 await shutdownOtel();
568 await closePrisma();
569 await closeRedis();
570 log.info("shutdown complete");
571 process.exit(0);
572 } catch (err) {
573 log.error({ err: (err as Error).message }, "error during shutdown");
574 process.exit(1);
575 }
576 };
577 process.on("SIGTERM", () => void shutdown("SIGTERM"));
578 process.on("SIGINT", () => void shutdown("SIGINT"));
579}
580
581main().catch((err) => {
582
583
584 console.error("factory worker failed to start:", err);
585 process.exit(1);
586});
587