notifkit 0.1.3 → 0.1.4

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.
Files changed (95) hide show
  1. package/README.md +95 -79
  2. package/dist/index.d.mts +199 -135
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
  7. package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
  8. package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
  9. package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
  10. package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
  11. package/dist/main-BNJtzY61.mjs.map +1 -0
  12. package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
  13. package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
  14. package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
  15. package/dist/main-CiigNpsP.mjs.map +1 -0
  16. package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
  17. package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
  18. package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
  19. package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
  20. package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
  21. package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
  22. package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
  23. package/dist/src-vG79L-8m.mjs.map +1 -0
  24. package/drizzle/0002_wide_colleen_wing.sql +2 -0
  25. package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
  26. package/drizzle/0004_pretty_bruce_banner.sql +1 -0
  27. package/drizzle/meta/0002_snapshot.json +1460 -0
  28. package/drizzle/meta/0003_snapshot.json +1460 -0
  29. package/drizzle/meta/0004_snapshot.json +1470 -0
  30. package/drizzle/meta/_journal.json +21 -0
  31. package/package.json +2 -1
  32. package/src/client.ts +412 -0
  33. package/src/config/index.ts +107 -0
  34. package/src/contracts/common.ts +28 -0
  35. package/src/contracts/envelope.ts +31 -0
  36. package/src/contracts/events/notification-ai-pending.ts +18 -0
  37. package/src/contracts/events/notification-canceled.ts +7 -0
  38. package/src/contracts/events/notification-created.ts +14 -0
  39. package/src/contracts/events/notification-delivered.ts +17 -0
  40. package/src/contracts/events/notification-dispatched.ts +45 -0
  41. package/src/contracts/events/notification-enriched.ts +46 -0
  42. package/src/contracts/events/notification-failed.ts +19 -0
  43. package/src/contracts/events/notification-requested.ts +36 -0
  44. package/src/contracts/events/notification-scheduled.ts +9 -0
  45. package/src/contracts/events/notification-skipped.ts +9 -0
  46. package/src/contracts/helpers.ts +21 -0
  47. package/src/contracts/index.ts +46 -0
  48. package/src/contracts/metadata.ts +10 -0
  49. package/src/contracts/registry.ts +88 -0
  50. package/src/contracts/sdk.ts +242 -0
  51. package/src/contracts/streams.ts +62 -0
  52. package/src/db/index.ts +69 -0
  53. package/src/db/schema.ts +412 -0
  54. package/src/idempotency/index.ts +50 -0
  55. package/src/index.ts +19 -0
  56. package/src/logger/index.ts +60 -0
  57. package/src/metrics/index.ts +53 -0
  58. package/src/queue/index.ts +501 -0
  59. package/src/rate-limiter/index.ts +210 -0
  60. package/src/redis/index.ts +89 -0
  61. package/src/repositories/index.ts +1246 -0
  62. package/src/server.ts +277 -0
  63. package/src/services/ai/main.ts +404 -0
  64. package/src/services/api/handlers.ts +1734 -0
  65. package/src/services/api/http.ts +64 -0
  66. package/src/services/api/main.ts +693 -0
  67. package/src/services/api/router.ts +82 -0
  68. package/src/services/delivery/main.ts +842 -0
  69. package/src/services/delivery/throttle.ts +71 -0
  70. package/src/services/engine/main.ts +827 -0
  71. package/src/services/enricher/main.ts +594 -0
  72. package/src/services/events/main.ts +365 -0
  73. package/src/services/scheduler/main.ts +319 -0
  74. package/src/services/workflow/main.ts +627 -0
  75. package/src/shared/batch-processor.ts +67 -0
  76. package/src/shared/cache.ts +47 -0
  77. package/src/shared/circuit-breaker.ts +74 -0
  78. package/src/shared/dataloader.ts +41 -0
  79. package/src/shared/events.ts +3 -0
  80. package/src/shared/index.ts +39 -0
  81. package/src/shared/semaphore.ts +33 -0
  82. package/src/shared/utils.ts +64 -0
  83. package/src/templates/cache.ts +32 -0
  84. package/src/templates/index.ts +69 -0
  85. package/src/templates/render.ts +128 -0
  86. package/src/transport/index.ts +96 -0
  87. package/src/unsubscribe/index.ts +127 -0
  88. package/src/workers/health.ts +31 -0
  89. package/src/workers/index.ts +266 -0
  90. package/src/workflows/index.ts +2 -0
  91. package/src/workflows/registry.ts +21 -0
  92. package/src/workflows/sdk.ts +106 -0
  93. package/dist/main-CAH0_Q6d.mjs.map +0 -1
  94. package/dist/main-CCfc45ev.mjs.map +0 -1
  95. package/dist/src-C-PfEDMY.mjs.map +0 -1
@@ -0,0 +1,365 @@
1
+ import { loadEnv, readBaseConfig } from "@/index.js";
2
+ import { createLogger } from "@/index.js";
3
+ import { RedisClient } from "@/index.js";
4
+ import {
5
+ StreamConsumer,
6
+ PendingMessageScanner,
7
+ StreamProducer,
8
+ type StreamMessage,
9
+ } from "@/index.js";
10
+ import { BaseWorker } from "@/index.js";
11
+ import { STREAMS, CONSUMER_GROUPS, buildStreamEvent } from "@/index.js";
12
+ import { type StreamName } from "@/contracts/streams.js";
13
+ import { createDatabase } from "@/db/index.js";
14
+ import { workflowWaiters, workflowSteps, workflowInstances, messageLogs } from "@/db/schema.js";
15
+ import { eq, and, or, isNull, gt } from "drizzle-orm";
16
+ import { type WorkerOptions } from "@/shared/index.js";
17
+ import { startHealthReporter } from "@/workers/index.js";
18
+
19
+ loadEnv();
20
+ const config = readBaseConfig();
21
+ let logger: ReturnType<typeof createLogger>;
22
+ let redis: RedisClient;
23
+ let sql: any;
24
+ let db: any;
25
+
26
+ let consumer: StreamConsumer;
27
+ let pendingScanner: PendingMessageScanner;
28
+ let worker: BaseWorker;
29
+ let healthInterval: NodeJS.Timeout | null = null;
30
+
31
+ let workflowProducer: StreamProducer;
32
+
33
+ export interface EventWorkerOptions extends WorkerOptions {
34
+ db: any;
35
+ workflowProducer: any;
36
+ }
37
+
38
+ export class EventWorker extends BaseWorker {
39
+ private readonly dbConn: any;
40
+ private readonly workflowProducer: any;
41
+ private messageLogBuffer: { log: any; resolve: () => void; reject: (err: any) => void }[] = [];
42
+ private eventBuffer: {
43
+ producer: any;
44
+ event: any;
45
+ resolve: () => void;
46
+ reject: (err: any) => void;
47
+ }[] = [];
48
+ private flushInterval: NodeJS.Timeout | null = null;
49
+ private isFlushing = false;
50
+
51
+ constructor(options: EventWorkerOptions) {
52
+ super(options);
53
+ this.dbConn = options.db;
54
+ this.workflowProducer = options.workflowProducer;
55
+ this.startFlushInterval();
56
+ }
57
+
58
+ private startFlushInterval() {
59
+ this.flushInterval = setInterval(() => {
60
+ void this.flushLogs();
61
+ }, config.LOG_FLUSH_INTERVAL_MS);
62
+ }
63
+
64
+ private async flushLogs() {
65
+ if (this.isFlushing || (this.messageLogBuffer.length === 0 && this.eventBuffer.length === 0))
66
+ return;
67
+ this.isFlushing = true;
68
+
69
+ const startTime = Date.now();
70
+ const batch = this.messageLogBuffer;
71
+ this.messageLogBuffer = [];
72
+
73
+ const events = this.eventBuffer;
74
+ this.eventBuffer = [];
75
+
76
+ try {
77
+ if (batch.length > 0) {
78
+ await this.dbConn
79
+ .insert(messageLogs)
80
+ .values(batch.map((b) => b.log))
81
+ .onConflictDoNothing(); // Idempotent insert
82
+ for (const b of batch) b.resolve();
83
+
84
+ this.logger.info(
85
+ {
86
+ batchSize: batch.length,
87
+ latencyMs: Date.now() - startTime,
88
+ },
89
+ "flushed delivery logs to database",
90
+ );
91
+ }
92
+
93
+ if (events.length > 0) {
94
+ const byProducer = new Map<any, typeof events>();
95
+ for (const item of events) {
96
+ if (!byProducer.has(item.producer)) byProducer.set(item.producer, []);
97
+ byProducer.get(item.producer)!.push(item);
98
+ }
99
+ for (const [producer, evts] of byProducer) {
100
+ await producer.publishBatch(evts.map((e) => e.event));
101
+ for (const e of evts) e.resolve();
102
+ }
103
+ }
104
+ } catch (error: any) {
105
+ this.logger.error(
106
+ {
107
+ batchSize: batch.length,
108
+ error: error.message,
109
+ },
110
+ "failed to flush delivery logs — dropping batch",
111
+ );
112
+ for (const b of batch) b.reject(error);
113
+ for (const e of events) e.reject(error);
114
+ } finally {
115
+ this.isFlushing = false;
116
+ }
117
+ }
118
+
119
+ override async stop(): Promise<void> {
120
+ if (this.flushInterval) clearInterval(this.flushInterval);
121
+ await super.stop();
122
+ await this.flushLogs();
123
+ }
124
+
125
+ override async process(message: StreamMessage): Promise<void> {
126
+ const { event } = message;
127
+ const publishPromises: Promise<void>[] = [];
128
+
129
+ if (
130
+ event.type === "notification.dispatched" ||
131
+ event.type === "notification.delivered" ||
132
+ event.type === "notification.failed"
133
+ ) {
134
+ const payload = event.payload as any;
135
+ if (this.messageLogBuffer.length >= config.LOG_BUFFER_MAX_SIZE) {
136
+ this.logger.warn(
137
+ { dropped: 1, bufferSize: this.messageLogBuffer.length },
138
+ "log buffer full — dropping oldest delivery log",
139
+ );
140
+ const dropped = this.messageLogBuffer.shift(); // Drop oldest to apply back-pressure/prevent OOM
141
+ if (dropped) dropped.reject(new Error("Dropped due to buffer overflow"));
142
+ }
143
+
144
+ const status =
145
+ event.type === "notification.dispatched"
146
+ ? "dispatched"
147
+ : event.type === "notification.delivered"
148
+ ? "delivered"
149
+ : "failed";
150
+
151
+ const kind = event.type === "notification.dispatched" ? "dispatched" : "attempt";
152
+
153
+ publishPromises.push(
154
+ new Promise((resolve, reject) => {
155
+ this.messageLogBuffer.push({
156
+ log: {
157
+ projectId: payload.projectId,
158
+ taskId: payload.taskId,
159
+ providerMessageId: payload.providerMessageId || null,
160
+ channel: payload.channel,
161
+ attempt: payload.attempt || 1,
162
+ kind,
163
+ status,
164
+ templateId: payload.templateId || null,
165
+ workflowInstanceId: payload.workflowInstanceId || null,
166
+ campaignId: payload.campaignId || null,
167
+ },
168
+ resolve,
169
+ reject,
170
+ });
171
+ }),
172
+ );
173
+ await Promise.all(publishPromises);
174
+ return;
175
+ }
176
+
177
+ if (event.type !== "event.received") {
178
+ return;
179
+ }
180
+
181
+ const payload = event.payload as any;
182
+ const eventName = payload.eventName;
183
+ const eventData = payload.payload;
184
+ const projectId = payload.projectId;
185
+
186
+ if (!eventName || !projectId) {
187
+ this.logger.warn("Missing eventName or projectId in payload");
188
+ return;
189
+ }
190
+
191
+ // Find matching waiters
192
+ const now = new Date();
193
+ const waiters = await this.dbConn
194
+ .select()
195
+ .from(workflowWaiters)
196
+ .where(
197
+ and(
198
+ eq(workflowWaiters.eventName, eventName),
199
+ eq(workflowWaiters.projectId, projectId),
200
+ or(isNull(workflowWaiters.expiresAt), gt(workflowWaiters.expiresAt, now)),
201
+ ),
202
+ );
203
+
204
+ const data: Record<string, any> = eventData && typeof eventData === "object" ? eventData : {};
205
+
206
+ for (const waiter of waiters) {
207
+ // Check match criteria
208
+ let matched = true;
209
+ const criteria = waiter.matchCriteria as Record<string, any>;
210
+ if (criteria && typeof criteria === "object") {
211
+ for (const [k, v] of Object.entries(criteria)) {
212
+ // simple dot notation check for a flat object or 1 level deep
213
+ let dataValue = data[k];
214
+ if (k.includes(".")) {
215
+ const parts = k.split(".");
216
+ dataValue = data;
217
+ for (const p of parts) {
218
+ if (dataValue) dataValue = dataValue[p];
219
+ }
220
+ }
221
+ if (dataValue !== v) {
222
+ matched = false;
223
+ break;
224
+ }
225
+ }
226
+ }
227
+
228
+ if (matched) {
229
+ this.logger.info(
230
+ { instanceId: waiter.instanceId, eventName },
231
+ "Event matched a sleeping workflow",
232
+ );
233
+
234
+ // The resume event must carry everything WorkflowWorker.process requires
235
+ // (name, instanceId, projectId) or it is dropped and the instance — whose
236
+ // waiter we are about to delete — is stranded forever.
237
+ const instance = (
238
+ await this.dbConn
239
+ .select()
240
+ .from(workflowInstances)
241
+ .where(eq(workflowInstances.id, waiter.instanceId))
242
+ .limit(1)
243
+ )[0];
244
+
245
+ if (!instance) {
246
+ this.logger.warn(
247
+ { instanceId: waiter.instanceId },
248
+ "waiter references a missing workflow instance — dropping waiter",
249
+ );
250
+ await this.dbConn.delete(workflowWaiters).where(eq(workflowWaiters.id, waiter.id));
251
+ continue;
252
+ }
253
+
254
+ // Find the step that is waiting
255
+ const steps = await this.dbConn
256
+ .select()
257
+ .from(workflowSteps)
258
+ .where(
259
+ and(
260
+ eq(workflowSteps.instanceId, waiter.instanceId),
261
+ eq(workflowSteps.action, "waitForEvent"),
262
+ ),
263
+ );
264
+
265
+ // Get the latest one that has no output
266
+ const pendingStep = steps.find((s: any) => s.output === null);
267
+
268
+ if (pendingStep) {
269
+ // Update the step with the event payload
270
+ await this.dbConn
271
+ .update(workflowSteps)
272
+ .set({ output: data })
273
+ .where(eq(workflowSteps.id, pendingStep.id));
274
+ }
275
+
276
+ // Wake up workflow. Publish BEFORE deleting the waiter: a crash after the
277
+ // delete but before the publish would leave the instance unrecoverable,
278
+ // whereas a duplicate resume is idempotent (the instance lock and the
279
+ // replayed step outputs both absorb it).
280
+ publishPromises.push(
281
+ new Promise((resolve, reject) => {
282
+ this.eventBuffer.push({
283
+ producer: this.workflowProducer,
284
+ event: buildStreamEvent(
285
+ "workflow.resumed",
286
+ {
287
+ projectId: instance.projectId,
288
+ instanceId: waiter.instanceId,
289
+ name: instance.name,
290
+ input: instance.input ?? {},
291
+ reason: "event_matched",
292
+ },
293
+ "events",
294
+ event.metadata.traceId,
295
+ ),
296
+ resolve,
297
+ reject,
298
+ });
299
+ }),
300
+ );
301
+
302
+ // Delete the waiter
303
+ await this.dbConn.delete(workflowWaiters).where(eq(workflowWaiters.id, waiter.id));
304
+
305
+ // The timer ZSET entry is left to expire: the poll loop re-checks the
306
+ // instance status and drops the entry if it is no longer pending.
307
+ }
308
+ }
309
+
310
+ await Promise.all(publishPromises);
311
+ }
312
+ }
313
+
314
+ export async function startEventWorker() {
315
+ logger = createLogger({ name: "event-worker", level: config.LOG_LEVEL });
316
+ redis = new RedisClient({ url: config.REDIS_URL, name: "events", logger });
317
+ const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: "events", logger });
318
+ sql = dbData.sql;
319
+ db = dbData.db;
320
+ workflowProducer = new StreamProducer({
321
+ redis: redis.native,
322
+ stream: STREAMS.WORKFLOW_INBOUND,
323
+ logger,
324
+ });
325
+ consumer = new StreamConsumer({
326
+ redis: redis.native,
327
+ stream: STREAMS.EVENTS_INBOUND as StreamName,
328
+ group: CONSUMER_GROUPS.EVENTS as any,
329
+ consumer: `events-${process.pid}`,
330
+ dlqStream: STREAMS.DEAD_LETTER,
331
+ batchSize: config.WORKER_CONCURRENCY,
332
+ logger,
333
+ });
334
+
335
+ pendingScanner = new PendingMessageScanner({
336
+ redis: redis.native,
337
+ stream: STREAMS.EVENTS_INBOUND as StreamName,
338
+ group: CONSUMER_GROUPS.EVENTS as any,
339
+ consumer: `events-${process.pid}`,
340
+ logger,
341
+ });
342
+
343
+ worker = new EventWorker({
344
+ consumer,
345
+ pendingScanner,
346
+ logger,
347
+ concurrency: config.WORKER_CONCURRENCY,
348
+ db,
349
+ workflowProducer,
350
+ });
351
+
352
+ healthInterval = startHealthReporter("events", worker, redis, logger);
353
+
354
+ logger.info("event worker starting");
355
+ await worker.start();
356
+ }
357
+
358
+ export async function stopEventWorker(): Promise<void> {
359
+ logger?.info("shutdown initiated");
360
+ if (healthInterval) clearInterval(healthInterval);
361
+ if (worker) await worker.stop();
362
+ if (sql) await sql.end();
363
+ if (redis) await redis.disconnect();
364
+ logger?.info("event worker stopped");
365
+ }
@@ -0,0 +1,319 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { loadEnv, readBaseConfig } from "@/index.js";
3
+ import { createLogger } from "@/index.js";
4
+ import { RedisClient, type Redis } from "@/index.js";
5
+ import {
6
+ StreamConsumer,
7
+ PendingMessageScanner,
8
+ StreamProducer,
9
+ type StreamMessage,
10
+ } from "@/index.js";
11
+ import { BaseWorker } from "@/index.js";
12
+ import {
13
+ STREAMS,
14
+ CONSUMER_GROUPS,
15
+ registry,
16
+ buildStreamEvent,
17
+ type NotificationScheduledPayload,
18
+ } from "@/index.js";
19
+ import { getPriorityBucket, type WorkerOptions, LUA_SCHEDULER_POLL } from "@/shared/index.js";
20
+ import { createDatabase } from "@/db/index.js";
21
+ import { scheduledPayloads } from "@/db/schema.js";
22
+ import { inArray } from "drizzle-orm";
23
+ import { startHealthReporter } from "@/workers/index.js";
24
+ // ─── Bootstrap ─────────────────────────────────────────────────────────────
25
+
26
+ loadEnv();
27
+ const config = readBaseConfig();
28
+
29
+ let logger: ReturnType<typeof createLogger>;
30
+ let redis: RedisClient;
31
+ let sql: any;
32
+ let db: any;
33
+
34
+ let consumer: StreamConsumer;
35
+ let pendingScanner: PendingMessageScanner;
36
+ let worker: BaseWorker;
37
+ let healthInterval: NodeJS.Timeout | null = null;
38
+ let pollTimeout: ReturnType<typeof setTimeout> | null = null;
39
+ let isPolling = false;
40
+
41
+ export interface SchedulerWorkerOptions extends WorkerOptions {
42
+ registry: any;
43
+ redis: Redis;
44
+ }
45
+
46
+ export class SchedulerWorker extends BaseWorker {
47
+ private readonly registry: any;
48
+ private readonly redisCli: Redis;
49
+
50
+ constructor(options: SchedulerWorkerOptions) {
51
+ super(options);
52
+ this.registry = options.registry;
53
+ this.redisCli = options.redis;
54
+ }
55
+ async process(message: StreamMessage): Promise<void> {
56
+ const { event } = message;
57
+
58
+ const payloadResult = this.registry.safeParsePayload("notification.scheduled", event.payload);
59
+ if (!payloadResult.success) {
60
+ this.logger.warn(
61
+ { messageId: message.id, issues: payloadResult.error.issues },
62
+ "invalid notification.scheduled payload — skipping",
63
+ );
64
+ return;
65
+ }
66
+
67
+ const scheduled = payloadResult.data as NotificationScheduledPayload;
68
+ const scheduledAt = new Date(scheduled.scheduledAt).getTime();
69
+
70
+ const taskData = JSON.stringify({
71
+ taskId: scheduled.taskId,
72
+ enrichedEventId: scheduled.enrichedEventId,
73
+ traceId: event.metadata.traceId,
74
+ });
75
+
76
+ const shard = parseInt(scheduled.taskId.slice(-1), 16) || 0;
77
+ const zsetKey = `notif:scheduled:zset:${shard}`;
78
+ await this.redisCli.zadd(zsetKey, scheduledAt, taskData);
79
+ this.logger.debug(
80
+ { taskId: scheduled.taskId, scheduledAt: scheduled.scheduledAt, shard },
81
+ "scheduled task queued in ZSET",
82
+ );
83
+ }
84
+ }
85
+
86
+ export async function executeSchedulerPoll(
87
+ redis: Redis,
88
+ outboundProducers: any,
89
+ logger: any,
90
+ db: any,
91
+ ): Promise<boolean> {
92
+ const lockKey = "notif:lock:scheduler:poll";
93
+ const lockOwner = randomUUID();
94
+ let acquired = false;
95
+ try {
96
+ const lockAcquired = await redis.set(lockKey, lockOwner, "EX", 30, "NX");
97
+ if (lockAcquired !== "OK") {
98
+ return false; // Another instance is currently polling
99
+ }
100
+ acquired = true;
101
+
102
+ const now = Date.now();
103
+ const visibilityTimeout = 60000; // 60 seconds
104
+ const perShardLimit = 100;
105
+ const pipeline = redis.pipeline();
106
+
107
+ for (let i = 0; i < 16; i++) {
108
+ pipeline.eval(
109
+ LUA_SCHEDULER_POLL,
110
+ 1,
111
+ `notif:scheduled:zset:${i}`,
112
+ now,
113
+ perShardLimit,
114
+ visibilityTimeout,
115
+ );
116
+ }
117
+
118
+ const results = await pipeline.exec();
119
+ const perShard = results?.map((res: any) => (res[0] ? [] : ((res[1] ?? []) as string[]))) ?? [];
120
+ const tasks = perShard.flat();
121
+ // A shard that came back full may still have due tasks behind it, which is
122
+ // what tells the caller to poll again rather than wait for the next tick.
123
+ const anyShardFull = perShard.some((shard) => shard.length >= perShardLimit);
124
+
125
+ if (tasks.length === 0) return false;
126
+
127
+ const parsedTasks = tasks
128
+ .map((t) => {
129
+ try {
130
+ return { taskStr: t, ...JSON.parse(t) };
131
+ } catch (err) {
132
+ logger.error({ taskStr: t, err }, "failed to parse scheduled task JSON");
133
+ return null;
134
+ }
135
+ })
136
+ .filter(Boolean) as any[];
137
+ if (parsedTasks.length === 0) return false;
138
+
139
+ const taskIds = parsedTasks.map((t) => t.taskId);
140
+ const dbPayloads = await db
141
+ .select()
142
+ .from(scheduledPayloads)
143
+ .where(inArray(scheduledPayloads.taskId, taskIds));
144
+ const payloadMap = new Map(dbPayloads.map((row: any) => [row.taskId, row.payload]));
145
+
146
+ const batchedEvents: Record<
147
+ "critical" | "high" | "normal" | "low",
148
+ Omit<any, "id" | "timestamp">[]
149
+ > = {
150
+ critical: [],
151
+ high: [],
152
+ normal: [],
153
+ low: [],
154
+ };
155
+
156
+ const cleanupPipeline = redis.pipeline();
157
+ const dbCleanupIds: string[] = [];
158
+
159
+ for (let i = 0; i < parsedTasks.length; i++) {
160
+ const { taskStr, taskId, traceId } = parsedTasks[i];
161
+ const payload = payloadMap.get(taskId);
162
+
163
+ if (!payload) {
164
+ logger.warn({ taskId }, "scheduled payload not found in Postgres — skipping release");
165
+ const shard = parseInt(taskId.slice(-1), 16) || 0;
166
+ cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);
167
+ continue;
168
+ }
169
+
170
+ const dispatchPayload = payload as any;
171
+ const p = getPriorityBucket(dispatchPayload.priority);
172
+
173
+ batchedEvents[p].push(
174
+ buildStreamEvent("notification.dispatched", dispatchPayload, "scheduler", traceId),
175
+ );
176
+
177
+ dbCleanupIds.push(taskId);
178
+ const shard = parseInt(taskId.slice(-1), 16) || 0;
179
+ cleanupPipeline.zrem(`notif:scheduled:zset:${shard}`, taskStr);
180
+ }
181
+
182
+ // Every bucket, not a hardcoded subset: a task binned into one that is not
183
+ // published here has already been added to the cleanup list, so it would be
184
+ // deleted from Redis and Postgres without ever being sent.
185
+ for (const p of Object.keys(batchedEvents) as (keyof typeof batchedEvents)[]) {
186
+ if (batchedEvents[p].length > 0) {
187
+ const producer = outboundProducers[p] ?? outboundProducers["normal"]!;
188
+ await producer!.publishBatch(batchedEvents[p]);
189
+ }
190
+ }
191
+
192
+ if (dbCleanupIds.length > 0) {
193
+ try {
194
+ await db.delete(scheduledPayloads).where(inArray(scheduledPayloads.taskId, dbCleanupIds));
195
+ } catch (err) {
196
+ logger.error(
197
+ { err },
198
+ "failed to delete scheduled payloads from Postgres, continuing with Redis cleanup",
199
+ );
200
+ }
201
+ }
202
+ await cleanupPipeline.exec();
203
+
204
+ logger.info({ count: parsedTasks.length }, "scheduled tasks released to outbound");
205
+
206
+ // "Poll again immediately" — the caller loops while this is true. The old
207
+ // test was `tasks.length === 100` against a total drawn from 16 shards, so
208
+ // a real backlog reported "nothing more" and waited for the next tick.
209
+ return anyShardFull;
210
+ } catch (err) {
211
+ logger.error({ err }, "error in scheduler polling loop");
212
+ return false;
213
+ } finally {
214
+ if (acquired) {
215
+ // Delete the lock only if we still own it
216
+ const lua = `
217
+ if redis.call("get", KEYS[1]) == ARGV[1] then
218
+ return redis.call("del", KEYS[1])
219
+ else
220
+ return 0
221
+ end
222
+ `;
223
+ await redis.eval(lua, 1, lockKey, lockOwner).catch(() => {});
224
+ }
225
+ }
226
+ }
227
+
228
+ export async function startSchedulerWorker() {
229
+ logger = createLogger({ name: "scheduler", level: config.LOG_LEVEL });
230
+ redis = new RedisClient({ url: config.REDIS_URL, name: "scheduler", logger });
231
+ const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: "scheduler", logger });
232
+ sql = dbData.sql;
233
+ db = dbData.db;
234
+ consumer = new StreamConsumer({
235
+ redis: redis.native,
236
+ stream: STREAMS.SCHEDULED,
237
+ group: CONSUMER_GROUPS.SCHEDULER,
238
+ consumer: `scheduler-${process.pid}`,
239
+ dlqStream: STREAMS.DEAD_LETTER,
240
+ batchSize: config.WORKER_CONCURRENCY,
241
+ logger,
242
+ });
243
+
244
+ pendingScanner = new PendingMessageScanner({
245
+ redis: redis.native,
246
+ stream: STREAMS.SCHEDULED,
247
+ group: CONSUMER_GROUPS.SCHEDULER,
248
+ consumer: `scheduler-${process.pid}`,
249
+ logger,
250
+ });
251
+
252
+ const outboundProducers = {
253
+ critical: new StreamProducer({
254
+ redis: redis.native,
255
+ stream: STREAMS.OUTBOUND_CRITICAL,
256
+ logger,
257
+ }),
258
+ normal: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_NORMAL, logger }),
259
+ low: new StreamProducer({ redis: redis.native, stream: STREAMS.OUTBOUND_LOW, logger }),
260
+ };
261
+
262
+ // ─── Stage 4a: Scheduled notification processor ─────────────────────────────
263
+ //
264
+ // Reads from SCHEDULED stream, parses, and queues tasks in a Redis ZSET.
265
+ // A background polling interval releases tasks when they are due.
266
+
267
+ worker = new SchedulerWorker({
268
+ consumer,
269
+ pendingScanner,
270
+ logger,
271
+ concurrency: config.WORKER_CONCURRENCY,
272
+ registry,
273
+ redis: redis.native,
274
+ });
275
+
276
+ // ─── Polling Loop ─────────────────────────────────────────────────────────
277
+ isPolling = true;
278
+ const pollLoop = async (): Promise<void> => {
279
+ if (!isPolling) return;
280
+ try {
281
+ const hasMore = await executeSchedulerPoll(redis.native, outboundProducers, logger, db);
282
+ if (isPolling) {
283
+ pollTimeout = setTimeout(() => void pollLoop(), hasMore ? 0 : 5000);
284
+ }
285
+ } catch (err) {
286
+ logger.error({ err }, "scheduler poll loop error");
287
+ if (isPolling) {
288
+ pollTimeout = setTimeout(() => void pollLoop(), 5000);
289
+ }
290
+ }
291
+ };
292
+ void pollLoop();
293
+
294
+ // ─── Health check interval ──────────────────────────────────────────────────
295
+
296
+ healthInterval = startHealthReporter("scheduler", worker, redis, logger);
297
+
298
+ logger.info({ env: config.NODE_ENV }, "scheduler starting");
299
+ await worker.start();
300
+ }
301
+
302
+ // ─── Shutdown ──────────────────────────────────────────────────────────────
303
+
304
+ export async function stopSchedulerWorker(): Promise<void> {
305
+ logger?.info("shutdown initiated");
306
+ if (healthInterval) {
307
+ clearInterval(healthInterval);
308
+ healthInterval = null;
309
+ }
310
+ if (pollTimeout) {
311
+ clearTimeout(pollTimeout);
312
+ pollTimeout = null;
313
+ }
314
+ isPolling = false;
315
+ if (worker) await worker.stop();
316
+ if (sql) await sql.end();
317
+ if (redis) await redis.disconnect();
318
+ logger?.info("scheduler stopped");
319
+ }