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.
- package/README.md +95 -79
- package/dist/index.d.mts +199 -135
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
- package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
- package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
- package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
- package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
- package/dist/main-BNJtzY61.mjs.map +1 -0
- package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
- package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
- package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
- package/dist/main-CiigNpsP.mjs.map +1 -0
- package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
- package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
- package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
- package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
- package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
- package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
- package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
- package/dist/src-vG79L-8m.mjs.map +1 -0
- package/drizzle/0002_wide_colleen_wing.sql +2 -0
- package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
- package/drizzle/0004_pretty_bruce_banner.sql +1 -0
- package/drizzle/meta/0002_snapshot.json +1460 -0
- package/drizzle/meta/0003_snapshot.json +1460 -0
- package/drizzle/meta/0004_snapshot.json +1470 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +2 -1
- package/src/client.ts +412 -0
- package/src/config/index.ts +107 -0
- package/src/contracts/common.ts +28 -0
- package/src/contracts/envelope.ts +31 -0
- package/src/contracts/events/notification-ai-pending.ts +18 -0
- package/src/contracts/events/notification-canceled.ts +7 -0
- package/src/contracts/events/notification-created.ts +14 -0
- package/src/contracts/events/notification-delivered.ts +17 -0
- package/src/contracts/events/notification-dispatched.ts +45 -0
- package/src/contracts/events/notification-enriched.ts +46 -0
- package/src/contracts/events/notification-failed.ts +19 -0
- package/src/contracts/events/notification-requested.ts +36 -0
- package/src/contracts/events/notification-scheduled.ts +9 -0
- package/src/contracts/events/notification-skipped.ts +9 -0
- package/src/contracts/helpers.ts +21 -0
- package/src/contracts/index.ts +46 -0
- package/src/contracts/metadata.ts +10 -0
- package/src/contracts/registry.ts +88 -0
- package/src/contracts/sdk.ts +242 -0
- package/src/contracts/streams.ts +62 -0
- package/src/db/index.ts +69 -0
- package/src/db/schema.ts +412 -0
- package/src/idempotency/index.ts +50 -0
- package/src/index.ts +19 -0
- package/src/logger/index.ts +60 -0
- package/src/metrics/index.ts +53 -0
- package/src/queue/index.ts +501 -0
- package/src/rate-limiter/index.ts +210 -0
- package/src/redis/index.ts +89 -0
- package/src/repositories/index.ts +1246 -0
- package/src/server.ts +277 -0
- package/src/services/ai/main.ts +404 -0
- package/src/services/api/handlers.ts +1734 -0
- package/src/services/api/http.ts +64 -0
- package/src/services/api/main.ts +693 -0
- package/src/services/api/router.ts +82 -0
- package/src/services/delivery/main.ts +842 -0
- package/src/services/delivery/throttle.ts +71 -0
- package/src/services/engine/main.ts +827 -0
- package/src/services/enricher/main.ts +594 -0
- package/src/services/events/main.ts +365 -0
- package/src/services/scheduler/main.ts +319 -0
- package/src/services/workflow/main.ts +627 -0
- package/src/shared/batch-processor.ts +67 -0
- package/src/shared/cache.ts +47 -0
- package/src/shared/circuit-breaker.ts +74 -0
- package/src/shared/dataloader.ts +41 -0
- package/src/shared/events.ts +3 -0
- package/src/shared/index.ts +39 -0
- package/src/shared/semaphore.ts +33 -0
- package/src/shared/utils.ts +64 -0
- package/src/templates/cache.ts +32 -0
- package/src/templates/index.ts +69 -0
- package/src/templates/render.ts +128 -0
- package/src/transport/index.ts +96 -0
- package/src/unsubscribe/index.ts +127 -0
- package/src/workers/health.ts +31 -0
- package/src/workers/index.ts +266 -0
- package/src/workflows/index.ts +2 -0
- package/src/workflows/registry.ts +21 -0
- package/src/workflows/sdk.ts +106 -0
- package/dist/main-CAH0_Q6d.mjs.map +0 -1
- package/dist/main-CCfc45ev.mjs.map +0 -1
- 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
|
+
}
|