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,629 @@
|
|
|
1
|
+
import { J as PendingMessageScanner, Jt as buildStreamEvent, L as CircuitBreaker, Q as metrics, Qt as OUTBOUND_STREAMS, R as BatchProcessor, W as getPriorityBucket, X as StreamProducer, Y as StreamConsumer, Yt as CONSUMER_GROUPS, an as registry, c as BaseWorker, dn as baseConfigSchema, en as STREAMS, et as createLogger, it as createDatabase, k as RedisClient, l as NonRetryableError, mn as parseConfig, ot as deliveryOutbox, pn as loadEnv, q as globalEmitter, rt as IdempotencyGuard, u as startHealthReporter, ut as scheduledPayloads, x as ContactRepository } from "./src-DrSN2wCg.mjs";
|
|
2
|
+
import { transportRegistry } from "./index.mjs";
|
|
3
|
+
//#region src/services/delivery/throttle.ts
|
|
4
|
+
const LUA_THROTTLE = `
|
|
5
|
+
local key = KEYS[1]
|
|
6
|
+
local now = tonumber(ARGV[1])
|
|
7
|
+
local windowSeconds = tonumber(ARGV[2])
|
|
8
|
+
local limit = tonumber(ARGV[3])
|
|
9
|
+
local member = ARGV[4]
|
|
10
|
+
|
|
11
|
+
local clearBefore = now - (windowSeconds * 1000)
|
|
12
|
+
|
|
13
|
+
-- Cleanup expired scores
|
|
14
|
+
redis.call('ZREMRANGEBYSCORE', key, 0, clearBefore)
|
|
15
|
+
|
|
16
|
+
-- Get current count
|
|
17
|
+
local count = redis.call('ZCARD', key)
|
|
18
|
+
|
|
19
|
+
if count >= limit then
|
|
20
|
+
-- Find the oldest score
|
|
21
|
+
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
|
|
22
|
+
if oldest and oldest[2] then
|
|
23
|
+
return {0, tonumber(oldest[2])}
|
|
24
|
+
end
|
|
25
|
+
return {0, now}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
-- Add new request
|
|
29
|
+
redis.call('ZADD', key, now, member)
|
|
30
|
+
redis.call('EXPIRE', key, windowSeconds * 2)
|
|
31
|
+
return {1, 0}
|
|
32
|
+
`;
|
|
33
|
+
async function throttleProvider(redis, channel, config, logger) {
|
|
34
|
+
const key = `rate-limit:provider:${channel}`;
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const zmember = `${now}:${Math.random()}`;
|
|
37
|
+
const result = await redis.eval(LUA_THROTTLE, 1, key, now.toString(), config.windowSeconds.toString(), config.limit.toString(), zmember);
|
|
38
|
+
const allowed = result[0] === 1;
|
|
39
|
+
const oldestTimestamp = result[1];
|
|
40
|
+
let retryAfterMs = 0;
|
|
41
|
+
if (!allowed) {
|
|
42
|
+
retryAfterMs = Math.max(0, oldestTimestamp + config.windowSeconds * 1e3 - now);
|
|
43
|
+
logger.warn({
|
|
44
|
+
channel,
|
|
45
|
+
limit: config.limit,
|
|
46
|
+
windowSeconds: config.windowSeconds,
|
|
47
|
+
retryAfterMs
|
|
48
|
+
}, "Provider rate limit hit — task must be rescheduled");
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
allowed,
|
|
52
|
+
retryAfterMs
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/services/delivery/main.ts
|
|
57
|
+
const deliveryConfigSchema = baseConfigSchema.extend({});
|
|
58
|
+
loadEnv();
|
|
59
|
+
const config = parseConfig(deliveryConfigSchema, process.env);
|
|
60
|
+
let logger;
|
|
61
|
+
let redis;
|
|
62
|
+
let sql;
|
|
63
|
+
let db;
|
|
64
|
+
let consumer;
|
|
65
|
+
let pendingScanner;
|
|
66
|
+
let worker;
|
|
67
|
+
let scheduledProducer;
|
|
68
|
+
let enrichedProducers;
|
|
69
|
+
let healthInterval = null;
|
|
70
|
+
global._telemetry = global._telemetry || {
|
|
71
|
+
count: 0,
|
|
72
|
+
insert: 0,
|
|
73
|
+
provider: 0,
|
|
74
|
+
flush: 0,
|
|
75
|
+
ack: 0,
|
|
76
|
+
dequeue: 0,
|
|
77
|
+
dbupdate: 0,
|
|
78
|
+
flushCount: 0
|
|
79
|
+
};
|
|
80
|
+
var DeliveryWorker = class extends BaseWorker {
|
|
81
|
+
transportRegistry;
|
|
82
|
+
idempotency;
|
|
83
|
+
redisCli;
|
|
84
|
+
scheduledProducer;
|
|
85
|
+
enrichedProducers;
|
|
86
|
+
contactRepo;
|
|
87
|
+
eventsProducer;
|
|
88
|
+
globalEmitter;
|
|
89
|
+
db;
|
|
90
|
+
eventProcessor;
|
|
91
|
+
outboxUpdateProcessor;
|
|
92
|
+
outboxInsertProcessor;
|
|
93
|
+
breakers = /* @__PURE__ */ new Map();
|
|
94
|
+
constructor(options) {
|
|
95
|
+
super(options);
|
|
96
|
+
this.transportRegistry = options.transportRegistry;
|
|
97
|
+
this.idempotency = options.idempotency;
|
|
98
|
+
this.redisCli = options.redis;
|
|
99
|
+
this.scheduledProducer = options.scheduledProducer;
|
|
100
|
+
this.enrichedProducers = options.enrichedProducers;
|
|
101
|
+
this.contactRepo = options.contactRepo;
|
|
102
|
+
this.eventsProducer = options.eventsProducer;
|
|
103
|
+
this.globalEmitter = options.globalEmitter;
|
|
104
|
+
this.db = options.db;
|
|
105
|
+
this.eventProcessor = new BatchProcessor(1e3, 100, async (events) => {
|
|
106
|
+
await this.eventsProducer.publishBatch(events);
|
|
107
|
+
return events.map(() => void 0);
|
|
108
|
+
});
|
|
109
|
+
this.outboxUpdateProcessor = new BatchProcessor(500, 100, async (updates) => {
|
|
110
|
+
const { sql } = await import("drizzle-orm");
|
|
111
|
+
const values = updates.map((update) => ({
|
|
112
|
+
taskId: update.taskId,
|
|
113
|
+
channel: update.channel,
|
|
114
|
+
destination: update.destination,
|
|
115
|
+
providerMessageId: update.providerMessageId
|
|
116
|
+
}));
|
|
117
|
+
const tDbUpdateStart = Date.now();
|
|
118
|
+
await this.db.insert(deliveryOutbox).values(values).onConflictDoUpdate({
|
|
119
|
+
target: [
|
|
120
|
+
deliveryOutbox.taskId,
|
|
121
|
+
deliveryOutbox.channel,
|
|
122
|
+
deliveryOutbox.destination
|
|
123
|
+
],
|
|
124
|
+
set: { providerMessageId: sql`EXCLUDED.provider_message_id` }
|
|
125
|
+
}).catch((e) => this.logger.error({ err: e }, "background update failed"));
|
|
126
|
+
global._telemetry.dbupdate += Date.now() - tDbUpdateStart;
|
|
127
|
+
global._telemetry.flushCount++;
|
|
128
|
+
return updates.map(() => void 0);
|
|
129
|
+
});
|
|
130
|
+
this.outboxInsertProcessor = new BatchProcessor(500, 10, async (tasks) => {
|
|
131
|
+
const values = tasks.map((task) => ({
|
|
132
|
+
taskId: task.taskId,
|
|
133
|
+
channel: task.channel,
|
|
134
|
+
destination: task.destination
|
|
135
|
+
}));
|
|
136
|
+
await this.db.insert(deliveryOutbox).values(values).onConflictDoNothing();
|
|
137
|
+
return tasks.map(() => true);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async stop() {
|
|
141
|
+
await Promise.all([
|
|
142
|
+
this.eventProcessor.flush(),
|
|
143
|
+
this.outboxUpdateProcessor.flush(),
|
|
144
|
+
this.outboxInsertProcessor.flush()
|
|
145
|
+
]);
|
|
146
|
+
await super.stop();
|
|
147
|
+
}
|
|
148
|
+
getBreaker(name) {
|
|
149
|
+
let breaker = this.breakers.get(name);
|
|
150
|
+
if (!breaker) {
|
|
151
|
+
breaker = new CircuitBreaker({
|
|
152
|
+
failureThreshold: 5,
|
|
153
|
+
resetTimeoutMs: 3e4
|
|
154
|
+
});
|
|
155
|
+
this.breakers.set(name, breaker);
|
|
156
|
+
}
|
|
157
|
+
return breaker;
|
|
158
|
+
}
|
|
159
|
+
async process(message, attempt = 1) {
|
|
160
|
+
const { event } = message;
|
|
161
|
+
const publishPromises = [];
|
|
162
|
+
const payloadResult = registry.safeParsePayload("notification.dispatched", event.payload);
|
|
163
|
+
if (!payloadResult.success) {
|
|
164
|
+
this.logger.warn({
|
|
165
|
+
messageId: message.id,
|
|
166
|
+
issues: payloadResult.error.issues
|
|
167
|
+
}, "invalid notification.dispatched payload — skipping");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const task = payloadResult.data;
|
|
171
|
+
const fallbackToNextChannel = async (reason) => {
|
|
172
|
+
if (task.fallbackChain && task.fallbackChain.length > 0 && task.recipient) {
|
|
173
|
+
const nextChannel = task.fallbackChain[0];
|
|
174
|
+
const remainingChain = task.fallbackChain.slice(1);
|
|
175
|
+
const fallbackPayload = {
|
|
176
|
+
projectId: task.projectId,
|
|
177
|
+
rawEventId: task.enrichedEventId,
|
|
178
|
+
recipientId: task.recipientId,
|
|
179
|
+
channel: nextChannel,
|
|
180
|
+
priority: task.priority,
|
|
181
|
+
templateId: task.templateId,
|
|
182
|
+
templateVariables: task.templateVariables,
|
|
183
|
+
aiPrompts: task.aiPrompts,
|
|
184
|
+
recipient: task.recipient,
|
|
185
|
+
scheduledAt: void 0,
|
|
186
|
+
fallbackChain: remainingChain.length > 0 ? remainingChain : void 0
|
|
187
|
+
};
|
|
188
|
+
const p = getPriorityBucket(task.priority);
|
|
189
|
+
await (this.enrichedProducers[p] ?? this.enrichedProducers["normal"]).publish(buildStreamEvent("notification.enriched", fallbackPayload, "delivery", event.metadata.traceId));
|
|
190
|
+
this.logger.info({
|
|
191
|
+
taskId: task.taskId,
|
|
192
|
+
reason,
|
|
193
|
+
nextChannel,
|
|
194
|
+
traceId: event.metadata.traceId
|
|
195
|
+
}, "Delivery failed completely, rolling over to next channel in fallback chain");
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
};
|
|
200
|
+
const transports = this.transportRegistry.getAll(task.channel);
|
|
201
|
+
if (transports.length === 0) {
|
|
202
|
+
this.logger.warn({
|
|
203
|
+
taskId: task.taskId,
|
|
204
|
+
channel: task.channel
|
|
205
|
+
}, "no transport registered for channel — dropping");
|
|
206
|
+
if (!await fallbackToNextChannel("no_transport")) {
|
|
207
|
+
this.globalEmitter.emit("delivery:failed", task.taskId, "no transport", task.channel, task.projectId);
|
|
208
|
+
await this.eventProcessor.add(buildStreamEvent("notification.failed", {
|
|
209
|
+
projectId: task.projectId,
|
|
210
|
+
taskId: task.taskId,
|
|
211
|
+
enrichedEventId: task.enrichedEventId,
|
|
212
|
+
recipientId: task.recipientId,
|
|
213
|
+
channel: task.channel,
|
|
214
|
+
failureReason: "no transport registered for channel",
|
|
215
|
+
failureCode: "no_transport",
|
|
216
|
+
retryable: false,
|
|
217
|
+
attempt,
|
|
218
|
+
templateId: task.templateId,
|
|
219
|
+
workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
|
|
220
|
+
campaignId: task.campaignId
|
|
221
|
+
}, "delivery", event.metadata.traceId));
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const limitConfig = transports[0].limits;
|
|
226
|
+
if (limitConfig) {
|
|
227
|
+
const { allowed, retryAfterMs } = await throttleProvider(this.redisCli, task.channel, limitConfig, this.logger);
|
|
228
|
+
if (!allowed) {
|
|
229
|
+
task.throttleAttemptCount = (task.throttleAttemptCount ?? 0) + 1;
|
|
230
|
+
const maxAttempts = task.deliveryOptions?.maxAttempts ?? 3;
|
|
231
|
+
if (task.throttleAttemptCount > maxAttempts) {
|
|
232
|
+
this.logger.warn({
|
|
233
|
+
messageId: message.id,
|
|
234
|
+
taskId: task.taskId,
|
|
235
|
+
attempts: task.throttleAttemptCount
|
|
236
|
+
}, "provider rate limit max attempts exceeded");
|
|
237
|
+
if (!await fallbackToNextChannel("provider_throttle_exceeded")) {
|
|
238
|
+
this.globalEmitter.emit("delivery:failed", task.taskId, "provider throttle exceeded", task.channel, task.projectId);
|
|
239
|
+
await this.eventProcessor.add(buildStreamEvent("notification.failed", {
|
|
240
|
+
projectId: task.projectId,
|
|
241
|
+
taskId: task.taskId,
|
|
242
|
+
enrichedEventId: task.enrichedEventId,
|
|
243
|
+
recipientId: task.recipientId,
|
|
244
|
+
channel: task.channel,
|
|
245
|
+
failureReason: "provider rate limit max attempts exceeded",
|
|
246
|
+
failureCode: "provider_throttle_exceeded",
|
|
247
|
+
retryable: false,
|
|
248
|
+
attempt,
|
|
249
|
+
templateId: task.templateId,
|
|
250
|
+
workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
|
|
251
|
+
campaignId: task.campaignId
|
|
252
|
+
}, "delivery", event.metadata.traceId));
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const { sql } = await import("drizzle-orm");
|
|
257
|
+
await this.db.insert(scheduledPayloads).values({
|
|
258
|
+
taskId: task.taskId,
|
|
259
|
+
payload: task
|
|
260
|
+
}).onConflictDoUpdate({
|
|
261
|
+
target: scheduledPayloads.taskId,
|
|
262
|
+
set: { payload: sql`EXCLUDED.payload` }
|
|
263
|
+
});
|
|
264
|
+
await this.scheduledProducer.publish(buildStreamEvent("notification.scheduled", {
|
|
265
|
+
projectId: task.projectId,
|
|
266
|
+
enrichedEventId: task.enrichedEventId,
|
|
267
|
+
taskId: task.taskId,
|
|
268
|
+
scheduledAt: new Date(Date.now() + retryAfterMs).toISOString(),
|
|
269
|
+
throttleAttemptCount: task.throttleAttemptCount
|
|
270
|
+
}, "delivery", `${task.taskId}:throttle:${Date.now()}`));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const tInsertStart = Date.now();
|
|
275
|
+
const idempotencyKey = task.taskId;
|
|
276
|
+
if (!await this.idempotency.checkAndMark(idempotencyKey, 60)) {
|
|
277
|
+
this.logger.info({
|
|
278
|
+
messageId: message.id,
|
|
279
|
+
taskId: task.taskId,
|
|
280
|
+
channel: task.channel,
|
|
281
|
+
attempt
|
|
282
|
+
}, "duplicate delivery — skipping");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
await this.outboxInsertProcessor.add(task);
|
|
287
|
+
const insertTime = Date.now() - tInsertStart;
|
|
288
|
+
publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.dispatched", {
|
|
289
|
+
projectId: task.projectId,
|
|
290
|
+
taskId: task.taskId,
|
|
291
|
+
enrichedEventId: task.enrichedEventId,
|
|
292
|
+
recipientId: task.recipientId,
|
|
293
|
+
channel: task.channel,
|
|
294
|
+
templateId: task.templateId,
|
|
295
|
+
attempt,
|
|
296
|
+
workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
|
|
297
|
+
campaignId: task.campaignId
|
|
298
|
+
}, "delivery", event.metadata.traceId)));
|
|
299
|
+
if (task.channel === "push") {
|
|
300
|
+
let lastResult = {
|
|
301
|
+
success: false,
|
|
302
|
+
error: "No transports"
|
|
303
|
+
};
|
|
304
|
+
for (const transport of transports) try {
|
|
305
|
+
const tProv = Date.now();
|
|
306
|
+
lastResult = await this.getBreaker(`${task.channel}:${transport.constructor.name}`).execute(async () => {
|
|
307
|
+
const timeoutMs = task.deliveryOptions?.timeoutMs ?? 1e4;
|
|
308
|
+
const controller = new AbortController();
|
|
309
|
+
const timeout = setTimeout(() => {
|
|
310
|
+
controller.abort(/* @__PURE__ */ new Error(`Transport timeout after ${timeoutMs}ms`));
|
|
311
|
+
}, timeoutMs);
|
|
312
|
+
try {
|
|
313
|
+
task.signal = controller.signal;
|
|
314
|
+
const res = await Promise.race([transport.send(task), new Promise((_, reject) => {
|
|
315
|
+
if (controller.signal.aborted) return reject(controller.signal.reason);
|
|
316
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason));
|
|
317
|
+
})]);
|
|
318
|
+
if (!res.success && !res.invalidToken) throw new Error(res.error ?? "Transport failed");
|
|
319
|
+
return res;
|
|
320
|
+
} finally {
|
|
321
|
+
clearTimeout(timeout);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
global._telemetry.provider += Date.now() - tProv;
|
|
325
|
+
if (lastResult.success || lastResult.invalidToken) break;
|
|
326
|
+
} catch (err) {
|
|
327
|
+
lastResult = {
|
|
328
|
+
success: false,
|
|
329
|
+
error: err.message
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (lastResult.invalidToken) {
|
|
333
|
+
await this.contactRepo.deactivate(task.projectId, task.recipientId, "push", task.destination);
|
|
334
|
+
this.logger.info({ token: task.destination }, "deactivated invalid push token");
|
|
335
|
+
this.globalEmitter.emit("delivery:failed", task.taskId, "invalidToken", task.channel, task.projectId);
|
|
336
|
+
metrics.deliveryFailed.inc({
|
|
337
|
+
channel: task.channel,
|
|
338
|
+
reason: "invalid_token"
|
|
339
|
+
});
|
|
340
|
+
publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.failed", {
|
|
341
|
+
projectId: task.projectId,
|
|
342
|
+
taskId: task.taskId,
|
|
343
|
+
enrichedEventId: task.enrichedEventId,
|
|
344
|
+
recipientId: task.recipientId,
|
|
345
|
+
channel: task.channel,
|
|
346
|
+
failureReason: "invalid_token",
|
|
347
|
+
failureCode: "push_failure",
|
|
348
|
+
retryable: false,
|
|
349
|
+
attempt,
|
|
350
|
+
templateId: task.templateId,
|
|
351
|
+
workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
|
|
352
|
+
campaignId: task.campaignId
|
|
353
|
+
}, "delivery", event.metadata.traceId)));
|
|
354
|
+
if (!await fallbackToNextChannel("invalid_token")) throw new NonRetryableError("Push delivery failed: invalid token");
|
|
355
|
+
} else if (lastResult.success) {
|
|
356
|
+
const providerMessageId = lastResult.providerMessageId || "push-success";
|
|
357
|
+
publishPromises.push(this.outboxUpdateProcessor.add({
|
|
358
|
+
taskId: task.taskId,
|
|
359
|
+
channel: task.channel,
|
|
360
|
+
destination: task.destination,
|
|
361
|
+
providerMessageId
|
|
362
|
+
}));
|
|
363
|
+
this.logger.debug({
|
|
364
|
+
taskId: task.taskId,
|
|
365
|
+
messageId: providerMessageId
|
|
366
|
+
}, "push delivered");
|
|
367
|
+
this.globalEmitter.emit("delivery:delivered", task.taskId, providerMessageId, task.channel, task.projectId);
|
|
368
|
+
metrics.deliverySuccess.inc({ channel: task.channel });
|
|
369
|
+
publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.delivered", {
|
|
370
|
+
projectId: task.projectId,
|
|
371
|
+
taskId: task.taskId,
|
|
372
|
+
enrichedEventId: task.enrichedEventId,
|
|
373
|
+
channel: task.channel,
|
|
374
|
+
deliveredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
375
|
+
providerMessageId,
|
|
376
|
+
templateId: task.templateId,
|
|
377
|
+
workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
|
|
378
|
+
campaignId: task.campaignId
|
|
379
|
+
}, "delivery", event.metadata.traceId)));
|
|
380
|
+
} else {
|
|
381
|
+
this.logger.warn({
|
|
382
|
+
taskId: task.taskId,
|
|
383
|
+
error: lastResult.error
|
|
384
|
+
}, "push delivery failed");
|
|
385
|
+
this.globalEmitter.emit("delivery:failed", task.taskId, lastResult.error ?? "push delivery failed", task.channel, task.projectId);
|
|
386
|
+
metrics.deliveryFailed.inc({
|
|
387
|
+
channel: task.channel,
|
|
388
|
+
reason: "push_error"
|
|
389
|
+
});
|
|
390
|
+
publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.failed", {
|
|
391
|
+
projectId: task.projectId,
|
|
392
|
+
taskId: task.taskId,
|
|
393
|
+
enrichedEventId: task.enrichedEventId,
|
|
394
|
+
recipientId: task.recipientId,
|
|
395
|
+
channel: task.channel,
|
|
396
|
+
failureReason: lastResult.error ?? "push delivery failed",
|
|
397
|
+
failureCode: "push_failure",
|
|
398
|
+
retryable: false,
|
|
399
|
+
attempt,
|
|
400
|
+
templateId: task.templateId,
|
|
401
|
+
workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
|
|
402
|
+
campaignId: task.campaignId
|
|
403
|
+
}, "delivery", event.metadata.traceId)));
|
|
404
|
+
if (!await fallbackToNextChannel("push_delivery_failed")) throw new NonRetryableError(lastResult.error ?? "Push delivery failed");
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
let result = {
|
|
408
|
+
success: false,
|
|
409
|
+
error: "No transports"
|
|
410
|
+
};
|
|
411
|
+
for (const transport of transports) try {
|
|
412
|
+
const tProv = Date.now();
|
|
413
|
+
result = await this.getBreaker(`${task.channel}:${transport.constructor.name}`).execute(async () => {
|
|
414
|
+
const timeoutMs = task.deliveryOptions?.timeoutMs ?? 1e4;
|
|
415
|
+
const controller = new AbortController();
|
|
416
|
+
const timeout = setTimeout(() => {
|
|
417
|
+
controller.abort(/* @__PURE__ */ new Error(`Transport timeout after ${timeoutMs}ms`));
|
|
418
|
+
}, timeoutMs);
|
|
419
|
+
try {
|
|
420
|
+
task.signal = controller.signal;
|
|
421
|
+
const res = await Promise.race([transport.send(task), new Promise((_, reject) => {
|
|
422
|
+
if (controller.signal.aborted) return reject(controller.signal.reason);
|
|
423
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason));
|
|
424
|
+
})]);
|
|
425
|
+
if (!res.success) throw new Error(res.error ?? "Transport failed");
|
|
426
|
+
return res;
|
|
427
|
+
} finally {
|
|
428
|
+
clearTimeout(timeout);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
global._telemetry.provider += Date.now() - tProv;
|
|
432
|
+
if (result.success) break;
|
|
433
|
+
} catch (err) {
|
|
434
|
+
result = {
|
|
435
|
+
success: false,
|
|
436
|
+
error: err.message
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (result.success) {
|
|
440
|
+
const providerMessageId = result.providerMessageId || "success";
|
|
441
|
+
publishPromises.push(this.outboxUpdateProcessor.add({
|
|
442
|
+
taskId: task.taskId,
|
|
443
|
+
channel: task.channel,
|
|
444
|
+
destination: task.destination,
|
|
445
|
+
providerMessageId
|
|
446
|
+
}));
|
|
447
|
+
this.globalEmitter.emit("delivery:delivered", task.taskId, providerMessageId, task.channel, task.projectId);
|
|
448
|
+
metrics.deliverySuccess.inc({ channel: task.channel });
|
|
449
|
+
publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.delivered", {
|
|
450
|
+
projectId: task.projectId,
|
|
451
|
+
taskId: task.taskId,
|
|
452
|
+
enrichedEventId: task.enrichedEventId,
|
|
453
|
+
channel: task.channel,
|
|
454
|
+
deliveredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
455
|
+
providerMessageId,
|
|
456
|
+
templateId: task.templateId,
|
|
457
|
+
workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
|
|
458
|
+
campaignId: task.campaignId
|
|
459
|
+
}, "delivery", event.metadata.traceId)));
|
|
460
|
+
} else publishPromises.push(this.eventProcessor.add(buildStreamEvent("notification.failed", {
|
|
461
|
+
projectId: task.projectId,
|
|
462
|
+
taskId: task.taskId,
|
|
463
|
+
enrichedEventId: task.enrichedEventId,
|
|
464
|
+
channel: task.channel,
|
|
465
|
+
failureReason: result.error ?? "delivery failed completely",
|
|
466
|
+
failureCode: "provider_error",
|
|
467
|
+
retryable: false,
|
|
468
|
+
attempt,
|
|
469
|
+
templateId: task.templateId,
|
|
470
|
+
workflowInstanceId: event.metadata.source === "workflow" ? event.metadata.traceId : void 0,
|
|
471
|
+
campaignId: task.campaignId
|
|
472
|
+
}, "delivery", event.metadata.traceId)));
|
|
473
|
+
if (!result.success) {
|
|
474
|
+
this.logger.warn({
|
|
475
|
+
taskId: task.taskId,
|
|
476
|
+
channel: task.channel,
|
|
477
|
+
error: result.error
|
|
478
|
+
}, "delivery failed completely across providers");
|
|
479
|
+
this.globalEmitter.emit("delivery:failed", task.taskId, result.error ?? "delivery failed completely", task.channel, task.projectId);
|
|
480
|
+
metrics.deliveryFailed.inc({
|
|
481
|
+
channel: task.channel,
|
|
482
|
+
reason: "provider_error"
|
|
483
|
+
});
|
|
484
|
+
if (!await fallbackToNextChannel("all_providers_failed")) throw new NonRetryableError(result.error ?? "delivery failed");
|
|
485
|
+
} else this.logger.info({
|
|
486
|
+
taskId: task.taskId,
|
|
487
|
+
channel: task.channel,
|
|
488
|
+
messageId: result.providerMessageId
|
|
489
|
+
}, "notification delivered");
|
|
490
|
+
}
|
|
491
|
+
const tFlushStart = Date.now();
|
|
492
|
+
await Promise.all(publishPromises).catch((err) => {
|
|
493
|
+
this.logger.error({
|
|
494
|
+
err,
|
|
495
|
+
taskId: task.taskId
|
|
496
|
+
}, "failed to publish post-dispatch events, swallowing error to prevent duplicate delivery");
|
|
497
|
+
});
|
|
498
|
+
const flushTime = Date.now() - tFlushStart;
|
|
499
|
+
await this.idempotency.markProcessed(idempotencyKey);
|
|
500
|
+
const t = global._telemetry = global._telemetry || {
|
|
501
|
+
count: 0,
|
|
502
|
+
insert: 0,
|
|
503
|
+
provider: 0,
|
|
504
|
+
flush: 0,
|
|
505
|
+
ack: 0
|
|
506
|
+
};
|
|
507
|
+
t.count++;
|
|
508
|
+
t.insert += insertTime;
|
|
509
|
+
t.flush += flushTime;
|
|
510
|
+
if (t.count % 1e3 === 0) {
|
|
511
|
+
this.logger.debug(`[Metrics 1000 msgs] Dequeue: ${t.dequeue / 1e3}ms, DB Insert: ${t.insert / 1e3}ms, Provider: ${t.provider / 1e3}ms, Wait for Flush: ${t.flush / 1e3}ms, Ack: ${t.ack / 1e3}ms | DB Update (avg per flush): ${t.dbupdate / Math.max(1, t.flushCount)}ms`);
|
|
512
|
+
t.count = 0;
|
|
513
|
+
t.insert = 0;
|
|
514
|
+
t.provider = 0;
|
|
515
|
+
t.flush = 0;
|
|
516
|
+
t.dequeue = 0;
|
|
517
|
+
t.ack = 0;
|
|
518
|
+
t.dbupdate = 0;
|
|
519
|
+
t.flushCount = 0;
|
|
520
|
+
}
|
|
521
|
+
} catch (err) {
|
|
522
|
+
await this.idempotency.unmark(idempotencyKey).catch(() => {});
|
|
523
|
+
throw err;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
async function startDeliveryWorker() {
|
|
528
|
+
logger = createLogger({
|
|
529
|
+
name: "delivery",
|
|
530
|
+
level: config.LOG_LEVEL
|
|
531
|
+
});
|
|
532
|
+
redis = new RedisClient({
|
|
533
|
+
url: config.REDIS_URL,
|
|
534
|
+
name: "delivery",
|
|
535
|
+
logger
|
|
536
|
+
});
|
|
537
|
+
const dbData = createDatabase({
|
|
538
|
+
url: config.DATABASE_URL,
|
|
539
|
+
applicationName: "delivery",
|
|
540
|
+
logger
|
|
541
|
+
});
|
|
542
|
+
sql = dbData.sql;
|
|
543
|
+
db = dbData.db;
|
|
544
|
+
consumer = new StreamConsumer({
|
|
545
|
+
redis: redis.native,
|
|
546
|
+
stream: OUTBOUND_STREAMS,
|
|
547
|
+
group: CONSUMER_GROUPS.DELIVERY,
|
|
548
|
+
consumer: `delivery-${process.pid}`,
|
|
549
|
+
dlqStream: STREAMS.DEAD_LETTER,
|
|
550
|
+
batchSize: config.WORKER_CONCURRENCY,
|
|
551
|
+
logger
|
|
552
|
+
});
|
|
553
|
+
scheduledProducer = new StreamProducer({
|
|
554
|
+
redis: redis.native,
|
|
555
|
+
stream: STREAMS.SCHEDULED,
|
|
556
|
+
logger
|
|
557
|
+
});
|
|
558
|
+
pendingScanner = new PendingMessageScanner({
|
|
559
|
+
redis: redis.native,
|
|
560
|
+
stream: OUTBOUND_STREAMS,
|
|
561
|
+
group: CONSUMER_GROUPS.DELIVERY,
|
|
562
|
+
consumer: `delivery-${process.pid}`,
|
|
563
|
+
logger
|
|
564
|
+
});
|
|
565
|
+
enrichedProducers = {
|
|
566
|
+
critical: new StreamProducer({
|
|
567
|
+
redis: redis.native,
|
|
568
|
+
stream: STREAMS.ENRICHED_CRITICAL,
|
|
569
|
+
logger
|
|
570
|
+
}),
|
|
571
|
+
normal: new StreamProducer({
|
|
572
|
+
redis: redis.native,
|
|
573
|
+
stream: STREAMS.ENRICHED_NORMAL,
|
|
574
|
+
logger
|
|
575
|
+
}),
|
|
576
|
+
low: new StreamProducer({
|
|
577
|
+
redis: redis.native,
|
|
578
|
+
stream: STREAMS.ENRICHED_LOW,
|
|
579
|
+
logger
|
|
580
|
+
})
|
|
581
|
+
};
|
|
582
|
+
const contactRepo = new ContactRepository(db);
|
|
583
|
+
const idempotency = new IdempotencyGuard({
|
|
584
|
+
redis: redis.native,
|
|
585
|
+
keyPrefix: "notif:processed:delivery",
|
|
586
|
+
ttlSeconds: 86400
|
|
587
|
+
});
|
|
588
|
+
const eventsProducer = new StreamProducer({
|
|
589
|
+
redis: redis.native,
|
|
590
|
+
stream: STREAMS.EVENTS_INBOUND,
|
|
591
|
+
logger
|
|
592
|
+
});
|
|
593
|
+
worker = new DeliveryWorker({
|
|
594
|
+
consumer,
|
|
595
|
+
pendingScanner,
|
|
596
|
+
logger,
|
|
597
|
+
concurrency: config.WORKER_CONCURRENCY,
|
|
598
|
+
transportRegistry,
|
|
599
|
+
idempotency,
|
|
600
|
+
redis: redis.native,
|
|
601
|
+
scheduledProducer,
|
|
602
|
+
enrichedProducers,
|
|
603
|
+
contactRepo,
|
|
604
|
+
eventsProducer,
|
|
605
|
+
globalEmitter,
|
|
606
|
+
db
|
|
607
|
+
});
|
|
608
|
+
healthInterval = startHealthReporter("delivery", worker, redis, logger);
|
|
609
|
+
logger.info({
|
|
610
|
+
env: config.NODE_ENV,
|
|
611
|
+
channels: transportRegistry.registeredChannels()
|
|
612
|
+
}, "delivery starting");
|
|
613
|
+
await worker.start();
|
|
614
|
+
}
|
|
615
|
+
async function stopDeliveryWorker() {
|
|
616
|
+
logger?.info("shutdown initiated");
|
|
617
|
+
if (healthInterval) {
|
|
618
|
+
clearInterval(healthInterval);
|
|
619
|
+
healthInterval = null;
|
|
620
|
+
}
|
|
621
|
+
if (worker) await worker.stop();
|
|
622
|
+
if (sql) await sql.end();
|
|
623
|
+
if (redis) await redis.disconnect();
|
|
624
|
+
logger?.info("delivery stopped");
|
|
625
|
+
}
|
|
626
|
+
//#endregion
|
|
627
|
+
export { startDeliveryWorker, stopDeliveryWorker };
|
|
628
|
+
|
|
629
|
+
//# sourceMappingURL=main-ClEeP5qw.mjs.map
|