notifkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/dist/index.d.mts +9973 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +38 -0
- package/dist/index.mjs.map +1 -0
- package/dist/main-4H6vNXvy.mjs +392 -0
- package/dist/main-4H6vNXvy.mjs.map +1 -0
- package/dist/main-BHYZfBBq.mjs +224 -0
- package/dist/main-BHYZfBBq.mjs.map +1 -0
- package/dist/main-BIcKzWHE.mjs +430 -0
- package/dist/main-BIcKzWHE.mjs.map +1 -0
- package/dist/main-ClEeP5qw.mjs +629 -0
- package/dist/main-ClEeP5qw.mjs.map +1 -0
- package/dist/main-D-oWWzR3.mjs +234 -0
- package/dist/main-D-oWWzR3.mjs.map +1 -0
- package/dist/main-Dlfy9mWs.mjs +571 -0
- package/dist/main-Dlfy9mWs.mjs.map +1 -0
- package/dist/main-Dztc2dqR.mjs +294 -0
- package/dist/main-Dztc2dqR.mjs.map +1 -0
- package/dist/main-Ok9cQJ7q.mjs +1636 -0
- package/dist/main-Ok9cQJ7q.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/src-DrSN2wCg.mjs +3424 -0
- package/dist/src-DrSN2wCg.mjs.map +1 -0
- package/drizzle/0000_spotty_jack_flag.sql +189 -0
- package/drizzle/0001_stale_shotgun.sql +17 -0
- package/drizzle/meta/0000_snapshot.json +1314 -0
- package/drizzle/meta/0001_snapshot.json +1460 -0
- package/drizzle/meta/_journal.json +20 -0
- package/package.json +110 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { J as PendingMessageScanner, Jt as buildStreamEvent, U as LUA_SCHEDULER_POLL, W as getPriorityBucket, X as StreamProducer, Y as StreamConsumer, Yt as CONSUMER_GROUPS, an as registry, c as BaseWorker, en as STREAMS, et as createLogger, hn as readBaseConfig, it as createDatabase, k as RedisClient, pn as loadEnv, u as startHealthReporter, ut as scheduledPayloads } from "./src-DrSN2wCg.mjs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { inArray } from "drizzle-orm";
|
|
4
|
+
//#region src/services/scheduler/main.ts
|
|
5
|
+
loadEnv();
|
|
6
|
+
const config = readBaseConfig();
|
|
7
|
+
let logger;
|
|
8
|
+
let redis;
|
|
9
|
+
let sql$1;
|
|
10
|
+
let db;
|
|
11
|
+
let consumer;
|
|
12
|
+
let pendingScanner;
|
|
13
|
+
let worker;
|
|
14
|
+
let healthInterval = null;
|
|
15
|
+
let pollTimeout = null;
|
|
16
|
+
let isPolling = false;
|
|
17
|
+
var SchedulerWorker = class extends BaseWorker {
|
|
18
|
+
registry;
|
|
19
|
+
redisCli;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
super(options);
|
|
22
|
+
this.registry = options.registry;
|
|
23
|
+
this.redisCli = options.redis;
|
|
24
|
+
}
|
|
25
|
+
async process(message) {
|
|
26
|
+
const { event } = message;
|
|
27
|
+
const payloadResult = this.registry.safeParsePayload("notification.scheduled", event.payload);
|
|
28
|
+
if (!payloadResult.success) {
|
|
29
|
+
this.logger.warn({
|
|
30
|
+
messageId: message.id,
|
|
31
|
+
issues: payloadResult.error.issues
|
|
32
|
+
}, "invalid notification.scheduled payload — skipping");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const scheduled = payloadResult.data;
|
|
36
|
+
const scheduledAt = new Date(scheduled.scheduledAt).getTime();
|
|
37
|
+
const taskData = JSON.stringify({
|
|
38
|
+
taskId: scheduled.taskId,
|
|
39
|
+
enrichedEventId: scheduled.enrichedEventId,
|
|
40
|
+
traceId: event.metadata.traceId
|
|
41
|
+
});
|
|
42
|
+
const shard = parseInt(scheduled.taskId.slice(-1), 16) || 0;
|
|
43
|
+
const zsetKey = `notif:scheduled:zset:${shard}`;
|
|
44
|
+
await this.redisCli.zadd(zsetKey, scheduledAt, taskData);
|
|
45
|
+
this.logger.debug({
|
|
46
|
+
taskId: scheduled.taskId,
|
|
47
|
+
scheduledAt: scheduled.scheduledAt,
|
|
48
|
+
shard
|
|
49
|
+
}, "scheduled task queued in ZSET");
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
async function executeSchedulerPoll(redis, outboundProducers, logger, db) {
|
|
53
|
+
const lockKey = "notif:lock:scheduler:poll";
|
|
54
|
+
const lockOwner = randomUUID();
|
|
55
|
+
let acquired = false;
|
|
56
|
+
try {
|
|
57
|
+
if (await redis.set(lockKey, lockOwner, "EX", 30, "NX") !== "OK") return false;
|
|
58
|
+
acquired = true;
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const visibilityTimeout = 6e4;
|
|
61
|
+
const perShardLimit = 100;
|
|
62
|
+
const pipeline = redis.pipeline();
|
|
63
|
+
for (let i = 0; i < 16; i++) pipeline.eval(LUA_SCHEDULER_POLL, 1, `notif:scheduled:zset:${i}`, now, perShardLimit, visibilityTimeout);
|
|
64
|
+
const perShard = (await pipeline.exec())?.map((res) => res[0] ? [] : res[1] ?? []) ?? [];
|
|
65
|
+
const tasks = perShard.flat();
|
|
66
|
+
const anyShardFull = perShard.some((shard) => shard.length >= perShardLimit);
|
|
67
|
+
if (tasks.length === 0) return false;
|
|
68
|
+
const parsedTasks = tasks.map((t) => {
|
|
69
|
+
try {
|
|
70
|
+
return {
|
|
71
|
+
taskStr: t,
|
|
72
|
+
...JSON.parse(t)
|
|
73
|
+
};
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logger.error({
|
|
76
|
+
taskStr: t,
|
|
77
|
+
err
|
|
78
|
+
}, "failed to parse scheduled task JSON");
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}).filter(Boolean);
|
|
82
|
+
if (parsedTasks.length === 0) return false;
|
|
83
|
+
const taskIds = parsedTasks.map((t) => t.taskId);
|
|
84
|
+
const dbPayloads = await db.select().from(scheduledPayloads).where(inArray(scheduledPayloads.taskId, taskIds));
|
|
85
|
+
const payloadMap = new Map(dbPayloads.map((row) => [row.taskId, row.payload]));
|
|
86
|
+
const batchedEvents = {
|
|
87
|
+
critical: [],
|
|
88
|
+
high: [],
|
|
89
|
+
normal: [],
|
|
90
|
+
low: []
|
|
91
|
+
};
|
|
92
|
+
const cleanupPipeline = redis.pipeline();
|
|
93
|
+
const dbCleanupIds = [];
|
|
94
|
+
for (let i = 0; i < parsedTasks.length; i++) {
|
|
95
|
+
const { taskStr, taskId, traceId } = parsedTasks[i];
|
|
96
|
+
const payload = payloadMap.get(taskId);
|
|
97
|
+
if (!payload) {
|
|
98
|
+
logger.warn({ taskId }, "scheduled payload not found in Postgres — skipping release");
|
|
99
|
+
const shard = parseInt(taskId.slice(-1), 16) || 0;
|
|
100
|
+
cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const dispatchPayload = payload;
|
|
104
|
+
batchedEvents[getPriorityBucket(dispatchPayload.priority)].push(buildStreamEvent("notification.dispatched", dispatchPayload, "scheduler", traceId));
|
|
105
|
+
dbCleanupIds.push(taskId);
|
|
106
|
+
const shard = parseInt(taskId.slice(-1), 16) || 0;
|
|
107
|
+
cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);
|
|
108
|
+
}
|
|
109
|
+
for (const p of Object.keys(batchedEvents)) if (batchedEvents[p].length > 0) await (outboundProducers[p] ?? outboundProducers["normal"]).publishBatch(batchedEvents[p]);
|
|
110
|
+
if (dbCleanupIds.length > 0) try {
|
|
111
|
+
await db.delete(scheduledPayloads).where(inArray(scheduledPayloads.taskId, dbCleanupIds));
|
|
112
|
+
} catch (err) {
|
|
113
|
+
logger.error({ err }, "failed to delete scheduled payloads from Postgres, continuing with Redis cleanup");
|
|
114
|
+
}
|
|
115
|
+
await cleanupPipeline.exec();
|
|
116
|
+
logger.info({ count: parsedTasks.length }, "scheduled tasks released to outbound");
|
|
117
|
+
return anyShardFull;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
logger.error({ err }, "error in scheduler polling loop");
|
|
120
|
+
return false;
|
|
121
|
+
} finally {
|
|
122
|
+
if (acquired) await redis.eval(`
|
|
123
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
124
|
+
return redis.call("del", KEYS[1])
|
|
125
|
+
else
|
|
126
|
+
return 0
|
|
127
|
+
end
|
|
128
|
+
`, 1, lockKey, lockOwner).catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function startSchedulerWorker() {
|
|
132
|
+
logger = createLogger({
|
|
133
|
+
name: "scheduler",
|
|
134
|
+
level: config.LOG_LEVEL
|
|
135
|
+
});
|
|
136
|
+
redis = new RedisClient({
|
|
137
|
+
url: config.REDIS_URL,
|
|
138
|
+
name: "scheduler",
|
|
139
|
+
logger
|
|
140
|
+
});
|
|
141
|
+
const dbData = createDatabase({
|
|
142
|
+
url: config.DATABASE_URL,
|
|
143
|
+
applicationName: "scheduler",
|
|
144
|
+
logger
|
|
145
|
+
});
|
|
146
|
+
sql$1 = dbData.sql;
|
|
147
|
+
db = dbData.db;
|
|
148
|
+
consumer = new StreamConsumer({
|
|
149
|
+
redis: redis.native,
|
|
150
|
+
stream: STREAMS.SCHEDULED,
|
|
151
|
+
group: CONSUMER_GROUPS.SCHEDULER,
|
|
152
|
+
consumer: `scheduler-${process.pid}`,
|
|
153
|
+
dlqStream: STREAMS.DEAD_LETTER,
|
|
154
|
+
batchSize: config.WORKER_CONCURRENCY,
|
|
155
|
+
logger
|
|
156
|
+
});
|
|
157
|
+
pendingScanner = new PendingMessageScanner({
|
|
158
|
+
redis: redis.native,
|
|
159
|
+
stream: STREAMS.SCHEDULED,
|
|
160
|
+
group: CONSUMER_GROUPS.SCHEDULER,
|
|
161
|
+
consumer: `scheduler-${process.pid}`,
|
|
162
|
+
logger
|
|
163
|
+
});
|
|
164
|
+
const outboundProducers = {
|
|
165
|
+
critical: new StreamProducer({
|
|
166
|
+
redis: redis.native,
|
|
167
|
+
stream: STREAMS.OUTBOUND_CRITICAL,
|
|
168
|
+
logger
|
|
169
|
+
}),
|
|
170
|
+
normal: new StreamProducer({
|
|
171
|
+
redis: redis.native,
|
|
172
|
+
stream: STREAMS.OUTBOUND_NORMAL,
|
|
173
|
+
logger
|
|
174
|
+
}),
|
|
175
|
+
low: new StreamProducer({
|
|
176
|
+
redis: redis.native,
|
|
177
|
+
stream: STREAMS.OUTBOUND_LOW,
|
|
178
|
+
logger
|
|
179
|
+
})
|
|
180
|
+
};
|
|
181
|
+
worker = new SchedulerWorker({
|
|
182
|
+
consumer,
|
|
183
|
+
pendingScanner,
|
|
184
|
+
logger,
|
|
185
|
+
concurrency: config.WORKER_CONCURRENCY,
|
|
186
|
+
registry,
|
|
187
|
+
redis: redis.native
|
|
188
|
+
});
|
|
189
|
+
isPolling = true;
|
|
190
|
+
const pollLoop = async () => {
|
|
191
|
+
if (!isPolling) return;
|
|
192
|
+
try {
|
|
193
|
+
const hasMore = await executeSchedulerPoll(redis.native, outboundProducers, logger, db);
|
|
194
|
+
if (isPolling) pollTimeout = setTimeout(() => void pollLoop(), hasMore ? 0 : 5e3);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
logger.error({ err }, "scheduler poll loop error");
|
|
197
|
+
if (isPolling) pollTimeout = setTimeout(() => void pollLoop(), 5e3);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
pollLoop();
|
|
201
|
+
healthInterval = startHealthReporter("scheduler", worker, redis, logger);
|
|
202
|
+
logger.info({ env: config.NODE_ENV }, "scheduler starting");
|
|
203
|
+
await worker.start();
|
|
204
|
+
}
|
|
205
|
+
async function stopSchedulerWorker() {
|
|
206
|
+
logger?.info("shutdown initiated");
|
|
207
|
+
if (healthInterval) {
|
|
208
|
+
clearInterval(healthInterval);
|
|
209
|
+
healthInterval = null;
|
|
210
|
+
}
|
|
211
|
+
if (pollTimeout) {
|
|
212
|
+
clearTimeout(pollTimeout);
|
|
213
|
+
pollTimeout = null;
|
|
214
|
+
}
|
|
215
|
+
isPolling = false;
|
|
216
|
+
if (worker) await worker.stop();
|
|
217
|
+
if (sql$1) await sql$1.end();
|
|
218
|
+
if (redis) await redis.disconnect();
|
|
219
|
+
logger?.info("scheduler stopped");
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
export { startSchedulerWorker, stopSchedulerWorker };
|
|
223
|
+
|
|
224
|
+
//# sourceMappingURL=main-BHYZfBBq.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main-BHYZfBBq.mjs","names":["sql"],"sources":["../src/services/scheduler/main.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { loadEnv, readBaseConfig } from \"@/index.js\";\nimport { createLogger } from \"@/index.js\";\nimport { RedisClient, type Redis } from \"@/index.js\";\nimport {\n StreamConsumer,\n PendingMessageScanner,\n StreamProducer,\n type StreamMessage,\n} from \"@/index.js\";\nimport { BaseWorker } from \"@/index.js\";\nimport {\n STREAMS,\n CONSUMER_GROUPS,\n registry,\n buildStreamEvent,\n type NotificationScheduledPayload,\n} from \"@/index.js\";\nimport { getPriorityBucket, type WorkerOptions, LUA_SCHEDULER_POLL } from \"@/shared/index.js\";\nimport { createDatabase } from \"@/db/index.js\";\nimport { scheduledPayloads } from \"@/db/schema.js\";\nimport { inArray } from \"drizzle-orm\";\nimport { startHealthReporter } from \"@/workers/index.js\";\n// ─── Bootstrap ─────────────────────────────────────────────────────────────\n\nloadEnv();\nconst config = readBaseConfig();\n\nlet logger: ReturnType<typeof createLogger>;\nlet redis: RedisClient;\nlet sql: any;\nlet db: any;\n\nlet consumer: StreamConsumer;\nlet pendingScanner: PendingMessageScanner;\nlet worker: BaseWorker;\nlet healthInterval: NodeJS.Timeout | null = null;\nlet pollTimeout: ReturnType<typeof setTimeout> | null = null;\nlet isPolling = false;\n\nexport interface SchedulerWorkerOptions extends WorkerOptions {\n registry: any;\n redis: Redis;\n}\n\nexport class SchedulerWorker extends BaseWorker {\n private readonly registry: any;\n private readonly redisCli: Redis;\n\n constructor(options: SchedulerWorkerOptions) {\n super(options);\n this.registry = options.registry;\n this.redisCli = options.redis;\n }\n async process(message: StreamMessage): Promise<void> {\n const { event } = message;\n\n const payloadResult = this.registry.safeParsePayload(\"notification.scheduled\", event.payload);\n if (!payloadResult.success) {\n this.logger.warn(\n { messageId: message.id, issues: payloadResult.error.issues },\n \"invalid notification.scheduled payload — skipping\",\n );\n return;\n }\n\n const scheduled = payloadResult.data as NotificationScheduledPayload;\n const scheduledAt = new Date(scheduled.scheduledAt).getTime();\n\n const taskData = JSON.stringify({\n taskId: scheduled.taskId,\n enrichedEventId: scheduled.enrichedEventId,\n traceId: event.metadata.traceId,\n });\n\n const shard = parseInt(scheduled.taskId.slice(-1), 16) || 0;\n const zsetKey = `notif:scheduled:zset:${shard}`;\n await this.redisCli.zadd(zsetKey, scheduledAt, taskData);\n this.logger.debug(\n { taskId: scheduled.taskId, scheduledAt: scheduled.scheduledAt, shard },\n \"scheduled task queued in ZSET\",\n );\n }\n}\n\nexport async function executeSchedulerPoll(\n redis: Redis,\n outboundProducers: any,\n logger: any,\n db: any,\n): Promise<boolean> {\n const lockKey = \"notif:lock:scheduler:poll\";\n const lockOwner = randomUUID();\n let acquired = false;\n try {\n const lockAcquired = await redis.set(lockKey, lockOwner, \"EX\", 30, \"NX\");\n if (lockAcquired !== \"OK\") {\n return false; // Another instance is currently polling\n }\n acquired = true;\n\n const now = Date.now();\n const visibilityTimeout = 60000; // 60 seconds\n const perShardLimit = 100;\n const pipeline = redis.pipeline();\n\n for (let i = 0; i < 16; i++) {\n pipeline.eval(\n LUA_SCHEDULER_POLL,\n 1,\n `notif:scheduled:zset:${i}`,\n now,\n perShardLimit,\n visibilityTimeout,\n );\n }\n\n const results = await pipeline.exec();\n const perShard = results?.map((res: any) => (res[0] ? [] : ((res[1] ?? []) as string[]))) ?? [];\n const tasks = perShard.flat();\n // A shard that came back full may still have due tasks behind it, which is\n // what tells the caller to poll again rather than wait for the next tick.\n const anyShardFull = perShard.some((shard) => shard.length >= perShardLimit);\n\n if (tasks.length === 0) return false;\n\n const parsedTasks = tasks\n .map((t) => {\n try {\n return { taskStr: t, ...JSON.parse(t) };\n } catch (err) {\n logger.error({ taskStr: t, err }, \"failed to parse scheduled task JSON\");\n return null;\n }\n })\n .filter(Boolean) as any[];\n if (parsedTasks.length === 0) return false;\n\n const taskIds = parsedTasks.map((t) => t.taskId);\n const dbPayloads = await db\n .select()\n .from(scheduledPayloads)\n .where(inArray(scheduledPayloads.taskId, taskIds));\n const payloadMap = new Map(dbPayloads.map((row: any) => [row.taskId, row.payload]));\n\n const batchedEvents: Record<\n \"critical\" | \"high\" | \"normal\" | \"low\",\n Omit<any, \"id\" | \"timestamp\">[]\n > = {\n critical: [],\n high: [],\n normal: [],\n low: [],\n };\n\n const cleanupPipeline = redis.pipeline();\n const dbCleanupIds: string[] = [];\n\n for (let i = 0; i < parsedTasks.length; i++) {\n const { taskStr, taskId, traceId } = parsedTasks[i];\n const payload = payloadMap.get(taskId);\n\n if (!payload) {\n logger.warn({ taskId }, \"scheduled payload not found in Postgres — skipping release\");\n const shard = parseInt(taskId.slice(-1), 16) || 0;\n cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);\n continue;\n }\n\n const dispatchPayload = payload as any;\n const p = getPriorityBucket(dispatchPayload.priority);\n\n batchedEvents[p].push(\n buildStreamEvent(\"notification.dispatched\", dispatchPayload, \"scheduler\", traceId),\n );\n\n dbCleanupIds.push(taskId);\n const shard = parseInt(taskId.slice(-1), 16) || 0;\n cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);\n }\n\n // Every bucket, not a hardcoded subset: a task binned into one that is not\n // published here has already been added to the cleanup list, so it would be\n // deleted from Redis and Postgres without ever being sent.\n for (const p of Object.keys(batchedEvents) as (keyof typeof batchedEvents)[]) {\n if (batchedEvents[p].length > 0) {\n const producer = outboundProducers[p] ?? outboundProducers[\"normal\"]!;\n await producer!.publishBatch(batchedEvents[p]);\n }\n }\n\n if (dbCleanupIds.length > 0) {\n try {\n await db.delete(scheduledPayloads).where(inArray(scheduledPayloads.taskId, dbCleanupIds));\n } catch (err) {\n logger.error(\n { err },\n \"failed to delete scheduled payloads from Postgres, continuing with Redis cleanup\",\n );\n }\n }\n await cleanupPipeline.exec();\n\n logger.info({ count: parsedTasks.length }, \"scheduled tasks released to outbound\");\n\n // \"Poll again immediately\" — the caller loops while this is true. The old\n // test was `tasks.length === 100` against a total drawn from 16 shards, so\n // a real backlog reported \"nothing more\" and waited for the next tick.\n return anyShardFull;\n } catch (err) {\n logger.error({ err }, \"error in scheduler polling loop\");\n return false;\n } finally {\n if (acquired) {\n // Delete the lock only if we still own it\n const lua = `\n if redis.call(\"get\", KEYS[1]) == ARGV[1] then\n return redis.call(\"del\", KEYS[1])\n else\n return 0\n end\n `;\n await redis.eval(lua, 1, lockKey, lockOwner).catch(() => {});\n }\n }\n}\n\nexport async function startSchedulerWorker() {\n logger = createLogger({ name: \"scheduler\", level: config.LOG_LEVEL });\n redis = new RedisClient({ url: config.REDIS_URL, name: \"scheduler\", logger });\n const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: \"scheduler\", logger });\n sql = dbData.sql;\n db = dbData.db;\n consumer = new StreamConsumer({\n redis: redis.native,\n stream: STREAMS.SCHEDULED,\n group: CONSUMER_GROUPS.SCHEDULER,\n consumer: `scheduler-${process.pid}`,\n dlqStream: STREAMS.DEAD_LETTER,\n batchSize: config.WORKER_CONCURRENCY,\n logger,\n });\n\n pendingScanner = new PendingMessageScanner({\n redis: redis.native,\n stream: STREAMS.SCHEDULED,\n group: CONSUMER_GROUPS.SCHEDULER,\n consumer: `scheduler-${process.pid}`,\n logger,\n });\n\n const outboundProducers = {\n critical: new StreamProducer({\n redis: redis.native,\n stream: STREAMS.OUTBOUND_CRITICAL,\n logger,\n }),\n normal: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_NORMAL, logger }),\n low: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_LOW, logger }),\n };\n\n // ─── Stage 4a: Scheduled notification processor ─────────────────────────────\n //\n // Reads from SCHEDULED stream, parses, and queues tasks in a Redis ZSET.\n // A background polling interval releases tasks when they are due.\n\n worker = new SchedulerWorker({\n consumer,\n pendingScanner,\n logger,\n concurrency: config.WORKER_CONCURRENCY,\n registry,\n redis: redis.native,\n });\n\n // ─── Polling Loop ─────────────────────────────────────────────────────────\n isPolling = true;\n const pollLoop = async (): Promise<void> => {\n if (!isPolling) return;\n try {\n const hasMore = await executeSchedulerPoll(redis.native, outboundProducers, logger, db);\n if (isPolling) {\n pollTimeout = setTimeout(() => void pollLoop(), hasMore ? 0 : 5000);\n }\n } catch (err) {\n logger.error({ err }, \"scheduler poll loop error\");\n if (isPolling) {\n pollTimeout = setTimeout(() => void pollLoop(), 5000);\n }\n }\n };\n void pollLoop();\n\n // ─── Health check interval ──────────────────────────────────────────────────\n\n healthInterval = startHealthReporter(\"scheduler\", worker, redis, logger);\n\n logger.info({ env: config.NODE_ENV }, \"scheduler starting\");\n await worker.start();\n}\n\n// ─── Shutdown ──────────────────────────────────────────────────────────────\n\nexport async function stopSchedulerWorker(): Promise<void> {\n logger?.info(\"shutdown initiated\");\n if (healthInterval) {\n clearInterval(healthInterval);\n healthInterval = null;\n }\n if (pollTimeout) {\n clearTimeout(pollTimeout);\n pollTimeout = null;\n }\n isPolling = false;\n if (worker) await worker.stop();\n if (sql) await sql.end();\n if (redis) await redis.disconnect();\n logger?.info(\"scheduler stopped\");\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { E as UserRepository, J as PendingMessageScanner, Jt as buildStreamEvent, S as PreferenceRepository, T as TemplateRepository, W as getPriorityBucket, X as StreamProducer, Y as StreamConsumer, Yt as CONSUMER_GROUPS, Zt as INBOUND_STREAMS, an as registry, c as BaseWorker, en as STREAMS, et as createLogger, g as TemplateCache, hn as readBaseConfig, it as createDatabase, k as RedisClient, pn as loadEnv, rt as IdempotencyGuard, u as startHealthReporter, x as ContactRepository } from "./src-DrSN2wCg.mjs";
|
|
2
|
+
//#region src/services/enricher/main.ts
|
|
3
|
+
loadEnv();
|
|
4
|
+
const config = readBaseConfig();
|
|
5
|
+
let logger;
|
|
6
|
+
let redis;
|
|
7
|
+
let sql;
|
|
8
|
+
let db;
|
|
9
|
+
let consumer;
|
|
10
|
+
let pendingScanner;
|
|
11
|
+
let worker;
|
|
12
|
+
let healthInterval = null;
|
|
13
|
+
var EnricherWorker = class extends BaseWorker {
|
|
14
|
+
producers;
|
|
15
|
+
idempotency;
|
|
16
|
+
userRepo;
|
|
17
|
+
prefRepo;
|
|
18
|
+
contactRepo;
|
|
19
|
+
templateCache;
|
|
20
|
+
userBatch = [];
|
|
21
|
+
batchTimer = null;
|
|
22
|
+
eventBuffer = [];
|
|
23
|
+
flushTimer = null;
|
|
24
|
+
contactBatch = [];
|
|
25
|
+
contactBatchTimer = null;
|
|
26
|
+
async loadContacts(projectId, userIds) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
this.contactBatch.push({
|
|
29
|
+
projectId,
|
|
30
|
+
userIds,
|
|
31
|
+
resolve,
|
|
32
|
+
reject
|
|
33
|
+
});
|
|
34
|
+
if (this.contactBatch.length >= 500) {
|
|
35
|
+
if (this.contactBatchTimer) clearTimeout(this.contactBatchTimer);
|
|
36
|
+
this.flushContactBatch();
|
|
37
|
+
} else if (!this.contactBatchTimer) this.contactBatchTimer = setTimeout(() => void this.flushContactBatch(), 10);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async flushContactBatch() {
|
|
41
|
+
const batch = this.contactBatch;
|
|
42
|
+
this.contactBatch = [];
|
|
43
|
+
this.contactBatchTimer = null;
|
|
44
|
+
if (batch.length === 0) return;
|
|
45
|
+
try {
|
|
46
|
+
const byProject = /* @__PURE__ */ new Map();
|
|
47
|
+
for (const b of batch) {
|
|
48
|
+
if (!byProject.has(b.projectId)) byProject.set(b.projectId, []);
|
|
49
|
+
byProject.get(b.projectId).push(b);
|
|
50
|
+
}
|
|
51
|
+
for (const [projectId, items] of byProject) {
|
|
52
|
+
const userIds = Array.from(new Set(items.flatMap((i) => i.userIds)));
|
|
53
|
+
const contactsMap = await this.contactRepo.findActiveByUserIds(projectId, userIds);
|
|
54
|
+
for (const item of items) item.resolve(contactsMap);
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
for (const b of batch) b.reject(err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
constructor(options) {
|
|
61
|
+
super(options);
|
|
62
|
+
this.producers = options.producers;
|
|
63
|
+
this.idempotency = options.idempotency;
|
|
64
|
+
this.userRepo = options.userRepo;
|
|
65
|
+
this.prefRepo = options.prefRepo;
|
|
66
|
+
this.contactRepo = options.contactRepo;
|
|
67
|
+
this.templateCache = options.templateCache;
|
|
68
|
+
this.flushTimer = setInterval(() => void this.flushWorkerBuffers(), 100);
|
|
69
|
+
}
|
|
70
|
+
async stop() {
|
|
71
|
+
if (this.batchTimer) {
|
|
72
|
+
clearTimeout(this.batchTimer);
|
|
73
|
+
this.batchTimer = null;
|
|
74
|
+
}
|
|
75
|
+
if (this.flushTimer) {
|
|
76
|
+
clearInterval(this.flushTimer);
|
|
77
|
+
this.flushTimer = null;
|
|
78
|
+
}
|
|
79
|
+
if (this.contactBatchTimer) {
|
|
80
|
+
clearTimeout(this.contactBatchTimer);
|
|
81
|
+
this.contactBatchTimer = null;
|
|
82
|
+
}
|
|
83
|
+
await this.flushContactBatch();
|
|
84
|
+
await this.flushUserBatch();
|
|
85
|
+
await this.flushWorkerBuffers();
|
|
86
|
+
await super.stop();
|
|
87
|
+
}
|
|
88
|
+
async flushWorkerBuffers() {
|
|
89
|
+
if (this.eventBuffer.length === 0) return;
|
|
90
|
+
const events = this.eventBuffer;
|
|
91
|
+
this.eventBuffer = [];
|
|
92
|
+
try {
|
|
93
|
+
const byProducer = /* @__PURE__ */ new Map();
|
|
94
|
+
for (const e of events) {
|
|
95
|
+
if (!byProducer.has(e.producer)) byProducer.set(e.producer, []);
|
|
96
|
+
byProducer.get(e.producer).push(e);
|
|
97
|
+
}
|
|
98
|
+
for (const [producer, batch] of byProducer) {
|
|
99
|
+
await producer.publishBatch(batch.map((b) => b.event));
|
|
100
|
+
for (const b of batch) b.resolve();
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
this.logger.error({ err }, "failed to flush events in EnricherWorker");
|
|
104
|
+
for (const e of events) e.reject(err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async loadUser(projectId, userId) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
this.userBatch.push({
|
|
110
|
+
projectId,
|
|
111
|
+
userId,
|
|
112
|
+
resolve,
|
|
113
|
+
reject
|
|
114
|
+
});
|
|
115
|
+
if (this.userBatch.length >= 500) {
|
|
116
|
+
if (this.batchTimer) clearTimeout(this.batchTimer);
|
|
117
|
+
this.flushUserBatch();
|
|
118
|
+
} else if (!this.batchTimer) this.batchTimer = setTimeout(() => {
|
|
119
|
+
this.flushUserBatch();
|
|
120
|
+
}, 10);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
async flushUserBatch() {
|
|
124
|
+
const batch = this.userBatch;
|
|
125
|
+
this.userBatch = [];
|
|
126
|
+
this.batchTimer = null;
|
|
127
|
+
const byProject = /* @__PURE__ */ new Map();
|
|
128
|
+
for (const b of batch) {
|
|
129
|
+
if (!byProject.has(b.projectId)) byProject.set(b.projectId, []);
|
|
130
|
+
byProject.get(b.projectId).push(b);
|
|
131
|
+
}
|
|
132
|
+
for (const [projectId, reqs] of byProject.entries()) try {
|
|
133
|
+
const uniqueIds = Array.from(new Set(reqs.map((r) => r.userId)));
|
|
134
|
+
const profiles = await this.userRepo.findRecordsByIds(projectId, uniqueIds);
|
|
135
|
+
const profileMap = new Map(profiles.map((p) => [p.userId, p]));
|
|
136
|
+
for (const req of reqs) req.resolve(profileMap.get(req.userId) || null);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
for (const req of reqs) req.reject(err);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async process(message) {
|
|
142
|
+
const { event } = message;
|
|
143
|
+
const publishPromises = [];
|
|
144
|
+
let isRequested = true;
|
|
145
|
+
const requestedResult = registry.safeParsePayload("notification.requested", event.payload);
|
|
146
|
+
let createdResult = null;
|
|
147
|
+
if (!requestedResult.success) {
|
|
148
|
+
createdResult = registry.safeParsePayload("notification.created", event.payload);
|
|
149
|
+
isRequested = false;
|
|
150
|
+
}
|
|
151
|
+
if (!isRequested && (!createdResult || !createdResult.success)) {
|
|
152
|
+
const issues = createdResult ? createdResult.error.issues : requestedResult.error.issues;
|
|
153
|
+
this.logger.warn({
|
|
154
|
+
messageId: message.id,
|
|
155
|
+
issues
|
|
156
|
+
}, "invalid payload — skipping");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!isRequested) {
|
|
160
|
+
const raw = createdResult.data;
|
|
161
|
+
const dedupeId = `${raw.projectId}:${raw.idempotencyKey ?? event.id}`;
|
|
162
|
+
if (!await this.idempotency.checkAndMark(dedupeId, 60)) return;
|
|
163
|
+
try {
|
|
164
|
+
const profile = await this.userRepo.findRecordById(raw.projectId, raw.recipientId);
|
|
165
|
+
if (!profile) return;
|
|
166
|
+
const prefs = await this.prefRepo.findByUserId(raw.projectId, raw.recipientId);
|
|
167
|
+
const optedOutTypes = new Set(prefs.filter((p) => !p.optedIn).map((p) => p.eventType));
|
|
168
|
+
const enrichedPayload = {
|
|
169
|
+
projectId: raw.projectId,
|
|
170
|
+
rawEventId: event.id,
|
|
171
|
+
recipientId: raw.recipientId,
|
|
172
|
+
channel: raw.channel,
|
|
173
|
+
priority: raw.priority,
|
|
174
|
+
templateId: raw.templateId,
|
|
175
|
+
templateVariables: raw.payload,
|
|
176
|
+
recipient: {
|
|
177
|
+
id: profile.userId,
|
|
178
|
+
email: profile.email ?? void 0,
|
|
179
|
+
locale: profile.language ?? "en",
|
|
180
|
+
timezone: profile.timezone ?? "UTC",
|
|
181
|
+
preferences: {
|
|
182
|
+
optedOut: optedOutTypes.has(event.type) || profile.preferences.topics?.[event.type] === false,
|
|
183
|
+
channels: Object.entries(profile.preferences.channels ?? {}).filter(([_, enabled]) => !enabled).map(([channel]) => channel),
|
|
184
|
+
quietHours: profile.preferences.quietHours
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
scheduledAt: raw.scheduledAt
|
|
188
|
+
};
|
|
189
|
+
const p = getPriorityBucket(raw.priority);
|
|
190
|
+
const producer = this.producers[p] ?? this.producers["normal"];
|
|
191
|
+
publishPromises.push(new Promise((resolve, reject) => {
|
|
192
|
+
this.eventBuffer.push({
|
|
193
|
+
producer,
|
|
194
|
+
event: buildStreamEvent("notification.enriched", enrichedPayload, "enricher", event.metadata.traceId),
|
|
195
|
+
resolve,
|
|
196
|
+
reject
|
|
197
|
+
});
|
|
198
|
+
}));
|
|
199
|
+
this.logger.info({
|
|
200
|
+
messageId: message.id,
|
|
201
|
+
eventId: event.id,
|
|
202
|
+
recipientId: raw.recipientId
|
|
203
|
+
}, "event enriched");
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
await Promise.all(publishPromises).catch(async (err) => {
|
|
208
|
+
await this.idempotency.unmark(dedupeId).catch(() => {});
|
|
209
|
+
throw err;
|
|
210
|
+
});
|
|
211
|
+
await this.idempotency.markProcessed(dedupeId);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const raw = requestedResult.data;
|
|
215
|
+
const dedupeId = `${raw.projectId}:${raw.idempotencyKey ?? event.id}`;
|
|
216
|
+
if (!await this.idempotency.checkAndMark(dedupeId, 60)) return;
|
|
217
|
+
try {
|
|
218
|
+
let userIds = [];
|
|
219
|
+
if (raw.target.type === "user") userIds = [raw.target.userId];
|
|
220
|
+
else if (raw.target.type === "segment") {
|
|
221
|
+
userIds = await this.userRepo.findUsersBySegment(raw.projectId, raw.target.segment);
|
|
222
|
+
this.logger.info({
|
|
223
|
+
segment: raw.target.segment,
|
|
224
|
+
count: userIds.length
|
|
225
|
+
}, "Resolved segment");
|
|
226
|
+
} else if (raw.target.type === "topic") {
|
|
227
|
+
userIds = await this.userRepo.findUsersByTopic(raw.projectId, raw.target.topic);
|
|
228
|
+
this.logger.info({
|
|
229
|
+
topic: raw.target.topic,
|
|
230
|
+
count: userIds.length
|
|
231
|
+
}, "Resolved topic");
|
|
232
|
+
} else this.logger.warn({ target: raw.target }, "Segment/topic resolution not fully implemented");
|
|
233
|
+
const maxUsers = readBaseConfig().SEGMENT_MAX_USERS;
|
|
234
|
+
if (userIds.length > maxUsers) {
|
|
235
|
+
this.logger.error({
|
|
236
|
+
count: userIds.length,
|
|
237
|
+
max: maxUsers,
|
|
238
|
+
projectId: raw.projectId,
|
|
239
|
+
eventId: event.id
|
|
240
|
+
}, "Segment fan-out exceeds maximum allowed limit");
|
|
241
|
+
const p = getPriorityBucket(raw.priority ?? "normal");
|
|
242
|
+
await (this.producers[p] ?? this.producers.normal).publish(buildStreamEvent("notification.failed", {
|
|
243
|
+
projectId: raw.projectId,
|
|
244
|
+
rawEventId: event.id,
|
|
245
|
+
error: `Segment fan-out of ${userIds.length} exceeds limit of ${maxUsers}`
|
|
246
|
+
}, "enricher", event.metadata.traceId));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const topics = (raw.templateId ? await this.templateCache.getCachedTemplate(raw.projectId, raw.templateId) : null)?.topics ?? [];
|
|
250
|
+
const channels = raw.channels && raw.channels.length > 0 ? raw.channels : ["email"];
|
|
251
|
+
const isFallback = raw.fallback === true;
|
|
252
|
+
const channelsToProcess = isFallback ? [channels[0]] : channels;
|
|
253
|
+
const fallbackChain = isFallback ? channels.slice(1) : void 0;
|
|
254
|
+
const chunkArray = (arr, size) => Array.from({ length: Math.ceil(arr.length / size) }, (v, i) => arr.slice(i * size, i * size + size));
|
|
255
|
+
const chunks = chunkArray(userIds, 500);
|
|
256
|
+
for (const chunk of chunks) {
|
|
257
|
+
const profiles = (await Promise.all(chunk.map((id) => this.loadUser(raw.projectId, id)))).filter(Boolean);
|
|
258
|
+
const contactsByUser = await this.loadContacts(raw.projectId, profiles.map((profile) => profile.userId));
|
|
259
|
+
const batchedEvents = {
|
|
260
|
+
critical: [],
|
|
261
|
+
high: [],
|
|
262
|
+
normal: [],
|
|
263
|
+
low: []
|
|
264
|
+
};
|
|
265
|
+
for (const profile of profiles) for (const channel of channelsToProcess) {
|
|
266
|
+
const channelContacts = (contactsByUser.get(profile.userId) ?? []).filter((contact) => contact.channel === channel);
|
|
267
|
+
const destinations = channel === "push" ? [void 0] : channelContacts.map((c) => c.target);
|
|
268
|
+
if (destinations.length === 0) {
|
|
269
|
+
this.logger.info({
|
|
270
|
+
recipientId: profile.userId,
|
|
271
|
+
channel
|
|
272
|
+
}, "no active contact for channel");
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
for (const destination of destinations) {
|
|
276
|
+
const enrichedPayload = {
|
|
277
|
+
projectId: raw.projectId,
|
|
278
|
+
rawEventId: event.id,
|
|
279
|
+
recipientId: profile.userId,
|
|
280
|
+
channel,
|
|
281
|
+
priority: "normal",
|
|
282
|
+
templateId: raw.templateId,
|
|
283
|
+
templateVariables: raw.data,
|
|
284
|
+
aiPrompts: raw.aiPrompts,
|
|
285
|
+
recipient: {
|
|
286
|
+
id: profile.userId,
|
|
287
|
+
email: channel === "email" ? destination ?? profile.email ?? void 0 : profile.email ?? void 0,
|
|
288
|
+
phone: channel === "sms" ? destination : void 0,
|
|
289
|
+
webhook: channel === "webhook" ? destination : void 0,
|
|
290
|
+
locale: profile.language ?? "en",
|
|
291
|
+
timezone: profile.timezone ?? "UTC",
|
|
292
|
+
preferences: {
|
|
293
|
+
optedOut: topics.some((t) => profile.preferences.topics?.[t] === false),
|
|
294
|
+
channels: Object.entries(profile.preferences.channels ?? {}).filter(([_, enabled]) => !enabled).map(([channel]) => channel),
|
|
295
|
+
quietHours: profile.preferences.quietHours
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
scheduledAt: raw.scheduledAt,
|
|
299
|
+
fallbackChain: fallbackChain?.length ? fallbackChain : void 0,
|
|
300
|
+
campaignId: raw.campaignId
|
|
301
|
+
};
|
|
302
|
+
const msgPriority = raw.priority ?? "normal";
|
|
303
|
+
const p = getPriorityBucket(msgPriority);
|
|
304
|
+
enrichedPayload.priority = msgPriority;
|
|
305
|
+
batchedEvents[p].push(buildStreamEvent("notification.enriched", enrichedPayload, "enricher", event.metadata.traceId));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const p of [
|
|
309
|
+
"critical",
|
|
310
|
+
"normal",
|
|
311
|
+
"low"
|
|
312
|
+
]) if (batchedEvents[p].length > 0) {
|
|
313
|
+
const producer = this.producers[p] ?? this.producers.normal;
|
|
314
|
+
for (const ev of batchedEvents[p]) publishPromises.push(new Promise((resolve, reject) => {
|
|
315
|
+
this.eventBuffer.push({
|
|
316
|
+
producer,
|
|
317
|
+
event: ev,
|
|
318
|
+
resolve,
|
|
319
|
+
reject
|
|
320
|
+
});
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
this.logger.info({
|
|
325
|
+
messageId: message.id,
|
|
326
|
+
eventId: event.id,
|
|
327
|
+
target: raw.target.type,
|
|
328
|
+
traceId: event.metadata.traceId
|
|
329
|
+
}, "event enriched");
|
|
330
|
+
} catch (err) {
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
await Promise.all(publishPromises).catch(async (err) => {
|
|
334
|
+
await this.idempotency.unmark(dedupeId).catch(() => {});
|
|
335
|
+
throw err;
|
|
336
|
+
});
|
|
337
|
+
await this.idempotency.markProcessed(dedupeId);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
async function startEnricherWorker() {
|
|
341
|
+
logger = createLogger({
|
|
342
|
+
name: "enricher",
|
|
343
|
+
level: config.LOG_LEVEL
|
|
344
|
+
});
|
|
345
|
+
redis = new RedisClient({
|
|
346
|
+
url: config.REDIS_URL,
|
|
347
|
+
name: "enricher",
|
|
348
|
+
logger
|
|
349
|
+
});
|
|
350
|
+
const dbData = createDatabase({
|
|
351
|
+
url: config.DATABASE_URL,
|
|
352
|
+
applicationName: "enricher",
|
|
353
|
+
logger
|
|
354
|
+
});
|
|
355
|
+
sql = dbData.sql;
|
|
356
|
+
db = dbData.db;
|
|
357
|
+
consumer = new StreamConsumer({
|
|
358
|
+
redis: redis.native,
|
|
359
|
+
stream: INBOUND_STREAMS,
|
|
360
|
+
group: CONSUMER_GROUPS.ENRICHER,
|
|
361
|
+
consumer: `enricher-${process.pid}`,
|
|
362
|
+
dlqStream: STREAMS.DEAD_LETTER,
|
|
363
|
+
batchSize: config.WORKER_CONCURRENCY,
|
|
364
|
+
logger
|
|
365
|
+
});
|
|
366
|
+
pendingScanner = new PendingMessageScanner({
|
|
367
|
+
redis: redis.native,
|
|
368
|
+
stream: INBOUND_STREAMS,
|
|
369
|
+
group: CONSUMER_GROUPS.ENRICHER,
|
|
370
|
+
consumer: `enricher-${process.pid}`,
|
|
371
|
+
logger
|
|
372
|
+
});
|
|
373
|
+
const producers = {
|
|
374
|
+
critical: new StreamProducer({
|
|
375
|
+
redis: redis.native,
|
|
376
|
+
stream: STREAMS.ENRICHED_CRITICAL,
|
|
377
|
+
logger
|
|
378
|
+
}),
|
|
379
|
+
normal: new StreamProducer({
|
|
380
|
+
redis: redis.native,
|
|
381
|
+
stream: STREAMS.ENRICHED_NORMAL,
|
|
382
|
+
logger
|
|
383
|
+
}),
|
|
384
|
+
low: new StreamProducer({
|
|
385
|
+
redis: redis.native,
|
|
386
|
+
stream: STREAMS.ENRICHED_LOW,
|
|
387
|
+
logger
|
|
388
|
+
})
|
|
389
|
+
};
|
|
390
|
+
const idempotency = new IdempotencyGuard({
|
|
391
|
+
redis: redis.native,
|
|
392
|
+
keyPrefix: "notif:processed:enricher",
|
|
393
|
+
ttlSeconds: 86400
|
|
394
|
+
});
|
|
395
|
+
const userRepo = new UserRepository(db);
|
|
396
|
+
const prefRepo = new PreferenceRepository(db);
|
|
397
|
+
const contactRepo = new ContactRepository(db);
|
|
398
|
+
const templateCache = new TemplateCache(new TemplateRepository(db));
|
|
399
|
+
worker = new EnricherWorker({
|
|
400
|
+
consumer,
|
|
401
|
+
pendingScanner,
|
|
402
|
+
logger,
|
|
403
|
+
maxRetriesBeforeDlq: 5,
|
|
404
|
+
concurrency: config.WORKER_CONCURRENCY,
|
|
405
|
+
producers,
|
|
406
|
+
idempotency,
|
|
407
|
+
userRepo,
|
|
408
|
+
prefRepo,
|
|
409
|
+
contactRepo,
|
|
410
|
+
templateCache
|
|
411
|
+
});
|
|
412
|
+
healthInterval = startHealthReporter("enricher", worker, redis, logger);
|
|
413
|
+
logger.info({ env: config.NODE_ENV }, "enricher starting");
|
|
414
|
+
await worker.start();
|
|
415
|
+
}
|
|
416
|
+
async function stopEnricherWorker() {
|
|
417
|
+
logger?.info("shutdown initiated");
|
|
418
|
+
if (healthInterval) {
|
|
419
|
+
clearInterval(healthInterval);
|
|
420
|
+
healthInterval = null;
|
|
421
|
+
}
|
|
422
|
+
if (worker) await worker.stop();
|
|
423
|
+
if (sql) await sql.end();
|
|
424
|
+
if (redis) await redis.disconnect();
|
|
425
|
+
logger?.info("enricher stopped");
|
|
426
|
+
}
|
|
427
|
+
//#endregion
|
|
428
|
+
export { startEnricherWorker, stopEnricherWorker };
|
|
429
|
+
|
|
430
|
+
//# sourceMappingURL=main-BIcKzWHE.mjs.map
|