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,842 @@
|
|
|
1
|
+
import { loadEnv, parseConfig, baseConfigSchema } from "@/index.js";
|
|
2
|
+
import { createLogger } from "@/index.js";
|
|
3
|
+
import { RedisClient, type Redis } from "@/index.js";
|
|
4
|
+
import {
|
|
5
|
+
StreamConsumer,
|
|
6
|
+
PendingMessageScanner,
|
|
7
|
+
type StreamMessage,
|
|
8
|
+
StreamProducer,
|
|
9
|
+
} from "@/index.js";
|
|
10
|
+
import { BaseWorker } from "@/index.js";
|
|
11
|
+
import {
|
|
12
|
+
STREAMS,
|
|
13
|
+
OUTBOUND_STREAMS,
|
|
14
|
+
CONSUMER_GROUPS,
|
|
15
|
+
registry,
|
|
16
|
+
buildStreamEvent,
|
|
17
|
+
type NotificationDispatchedPayload,
|
|
18
|
+
} from "@/index.js";
|
|
19
|
+
import { type StreamName } from "@/contracts/streams.js";
|
|
20
|
+
import { createDatabase } from "@/db/index.js";
|
|
21
|
+
import { deliveryOutbox, scheduledPayloads } from "@/db/schema.js";
|
|
22
|
+
import { ContactRepository, IdempotencyGuard } from "@/index.js";
|
|
23
|
+
import { transportRegistry } from "@/index.js";
|
|
24
|
+
import { globalEmitter, getPriorityBucket, type WorkerOptions } from "@/shared/index.js";
|
|
25
|
+
import { startHealthReporter, NonRetryableError } from "@/workers/index.js";
|
|
26
|
+
import { BatchProcessor, CircuitBreaker } from "@/shared/index.js";
|
|
27
|
+
import { throttleProvider } from "./throttle.js";
|
|
28
|
+
import { metrics } from "@/metrics/index.js";
|
|
29
|
+
|
|
30
|
+
// ─── App-specific config ────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const deliveryConfigSchema = baseConfigSchema.extend({});
|
|
33
|
+
|
|
34
|
+
// ─── Bootstrap ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
loadEnv();
|
|
37
|
+
const config = parseConfig(deliveryConfigSchema, process.env);
|
|
38
|
+
|
|
39
|
+
let logger: ReturnType<typeof createLogger>;
|
|
40
|
+
let redis: RedisClient;
|
|
41
|
+
let sql: any;
|
|
42
|
+
let db: any;
|
|
43
|
+
|
|
44
|
+
let consumer: StreamConsumer;
|
|
45
|
+
let pendingScanner: PendingMessageScanner;
|
|
46
|
+
let worker: BaseWorker;
|
|
47
|
+
let scheduledProducer: StreamProducer;
|
|
48
|
+
let enrichedProducers: Record<string, StreamProducer>;
|
|
49
|
+
let healthInterval: NodeJS.Timeout | null = null;
|
|
50
|
+
|
|
51
|
+
(global as any)._telemetry = (global as any)._telemetry || {
|
|
52
|
+
count: 0,
|
|
53
|
+
insert: 0,
|
|
54
|
+
provider: 0,
|
|
55
|
+
flush: 0,
|
|
56
|
+
ack: 0,
|
|
57
|
+
dequeue: 0,
|
|
58
|
+
dbupdate: 0,
|
|
59
|
+
flushCount: 0,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface DeliveryWorkerOptions extends WorkerOptions {
|
|
63
|
+
transportRegistry: any;
|
|
64
|
+
idempotency: any;
|
|
65
|
+
redis: Redis;
|
|
66
|
+
scheduledProducer: any;
|
|
67
|
+
enrichedProducers: any;
|
|
68
|
+
contactRepo: any;
|
|
69
|
+
eventsProducer: any;
|
|
70
|
+
globalEmitter: any;
|
|
71
|
+
db: any;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class DeliveryWorker extends BaseWorker {
|
|
75
|
+
private readonly transportRegistry: any;
|
|
76
|
+
private readonly idempotency: any;
|
|
77
|
+
private readonly redisCli: Redis;
|
|
78
|
+
private readonly scheduledProducer: any;
|
|
79
|
+
private readonly enrichedProducers: any;
|
|
80
|
+
private readonly contactRepo: any;
|
|
81
|
+
private readonly eventsProducer: any;
|
|
82
|
+
private readonly globalEmitter: any;
|
|
83
|
+
private readonly db: any;
|
|
84
|
+
|
|
85
|
+
private eventProcessor: BatchProcessor<any, void>;
|
|
86
|
+
private outboxUpdateProcessor: BatchProcessor<any, void>;
|
|
87
|
+
private outboxInsertProcessor: BatchProcessor<any, boolean>;
|
|
88
|
+
private breakers = new Map<string, CircuitBreaker>();
|
|
89
|
+
|
|
90
|
+
constructor(options: DeliveryWorkerOptions) {
|
|
91
|
+
super(options);
|
|
92
|
+
this.transportRegistry = options.transportRegistry;
|
|
93
|
+
this.idempotency = options.idempotency;
|
|
94
|
+
this.redisCli = options.redis;
|
|
95
|
+
this.scheduledProducer = options.scheduledProducer;
|
|
96
|
+
this.enrichedProducers = options.enrichedProducers;
|
|
97
|
+
this.contactRepo = options.contactRepo;
|
|
98
|
+
this.eventsProducer = options.eventsProducer;
|
|
99
|
+
this.globalEmitter = options.globalEmitter;
|
|
100
|
+
this.db = options.db;
|
|
101
|
+
|
|
102
|
+
this.eventProcessor = new BatchProcessor<any, void>(1000, 100, async (events) => {
|
|
103
|
+
await this.eventsProducer.publishBatch(events);
|
|
104
|
+
return events.map(() => undefined as void);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
this.outboxUpdateProcessor = new BatchProcessor<any, void>(500, 100, async (updates) => {
|
|
108
|
+
const { sql } = await import("drizzle-orm");
|
|
109
|
+
const values = updates.map((update) => ({
|
|
110
|
+
taskId: update.taskId,
|
|
111
|
+
channel: update.channel,
|
|
112
|
+
destination: update.destination,
|
|
113
|
+
providerMessageId: update.providerMessageId,
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
const tDbUpdateStart = Date.now();
|
|
117
|
+
await this.db
|
|
118
|
+
.insert(deliveryOutbox)
|
|
119
|
+
.values(values)
|
|
120
|
+
.onConflictDoUpdate({
|
|
121
|
+
target: [deliveryOutbox.taskId, deliveryOutbox.channel, deliveryOutbox.destination],
|
|
122
|
+
set: { providerMessageId: sql`EXCLUDED.provider_message_id` },
|
|
123
|
+
})
|
|
124
|
+
.catch((e: any) => this.logger.error({ err: e }, "background update failed"));
|
|
125
|
+
|
|
126
|
+
(global as any)._telemetry.dbupdate += Date.now() - tDbUpdateStart;
|
|
127
|
+
(global as any)._telemetry.flushCount++;
|
|
128
|
+
return updates.map(() => undefined as void);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
this.outboxInsertProcessor = new BatchProcessor(500, 10, async (tasks) => {
|
|
132
|
+
const values = tasks.map((task: any) => ({
|
|
133
|
+
taskId: task.taskId,
|
|
134
|
+
channel: task.channel,
|
|
135
|
+
destination: task.destination,
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
await this.db.insert(deliveryOutbox).values(values).onConflictDoNothing();
|
|
139
|
+
return tasks.map(() => true);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
override async stop(): Promise<void> {
|
|
144
|
+
await Promise.all([
|
|
145
|
+
this.eventProcessor.flush(),
|
|
146
|
+
this.outboxUpdateProcessor.flush(),
|
|
147
|
+
this.outboxInsertProcessor.flush(),
|
|
148
|
+
]);
|
|
149
|
+
await super.stop();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private getBreaker(name: string): CircuitBreaker {
|
|
153
|
+
let breaker = this.breakers.get(name);
|
|
154
|
+
if (!breaker) {
|
|
155
|
+
breaker = new CircuitBreaker({ failureThreshold: 5, resetTimeoutMs: 30000 });
|
|
156
|
+
this.breakers.set(name, breaker);
|
|
157
|
+
}
|
|
158
|
+
return breaker;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async process(message: StreamMessage, attempt: number = 1): Promise<void> {
|
|
162
|
+
const { event } = message;
|
|
163
|
+
const publishPromises: Promise<void>[] = [];
|
|
164
|
+
|
|
165
|
+
const payloadResult = registry.safeParsePayload("notification.dispatched", event.payload);
|
|
166
|
+
if (!payloadResult.success) {
|
|
167
|
+
this.logger.warn(
|
|
168
|
+
{ messageId: message.id, issues: payloadResult.error.issues },
|
|
169
|
+
"invalid notification.dispatched payload — skipping",
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const task = payloadResult.data as NotificationDispatchedPayload;
|
|
175
|
+
|
|
176
|
+
const fallbackToNextChannel = async (reason: string) => {
|
|
177
|
+
if (task.fallbackChain && task.fallbackChain.length > 0 && task.recipient) {
|
|
178
|
+
const nextChannel = task.fallbackChain[0];
|
|
179
|
+
const remainingChain = task.fallbackChain.slice(1);
|
|
180
|
+
|
|
181
|
+
const fallbackPayload: any = {
|
|
182
|
+
// NotificationEnrichedPayload
|
|
183
|
+
projectId: task.projectId,
|
|
184
|
+
rawEventId: task.enrichedEventId,
|
|
185
|
+
recipientId: task.recipientId,
|
|
186
|
+
channel: nextChannel,
|
|
187
|
+
priority: task.priority,
|
|
188
|
+
templateId: task.templateId,
|
|
189
|
+
templateVariables: task.templateVariables,
|
|
190
|
+
aiPrompts: task.aiPrompts,
|
|
191
|
+
recipient: task.recipient,
|
|
192
|
+
scheduledAt: undefined,
|
|
193
|
+
fallbackChain: remainingChain.length > 0 ? remainingChain : undefined,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const p = getPriorityBucket(task.priority);
|
|
197
|
+
const producer = this.enrichedProducers[p] ?? this.enrichedProducers["normal"]!;
|
|
198
|
+
await producer.publish(
|
|
199
|
+
buildStreamEvent(
|
|
200
|
+
"notification.enriched",
|
|
201
|
+
fallbackPayload,
|
|
202
|
+
"delivery",
|
|
203
|
+
event.metadata.traceId,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
this.logger.info(
|
|
208
|
+
{ taskId: task.taskId, reason, nextChannel, traceId: event.metadata.traceId },
|
|
209
|
+
"Delivery failed completely, rolling over to next channel in fallback chain",
|
|
210
|
+
);
|
|
211
|
+
return true; // Indicates fallback was triggered
|
|
212
|
+
}
|
|
213
|
+
return false; // No fallback
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const transports = this.transportRegistry.getAll(task.channel);
|
|
217
|
+
|
|
218
|
+
if (transports.length === 0) {
|
|
219
|
+
this.logger.warn(
|
|
220
|
+
{ taskId: task.taskId, channel: task.channel },
|
|
221
|
+
"no transport registered for channel — dropping",
|
|
222
|
+
);
|
|
223
|
+
const fallbackTriggered = await fallbackToNextChannel("no_transport");
|
|
224
|
+
if (!fallbackTriggered) {
|
|
225
|
+
this.globalEmitter.emit(
|
|
226
|
+
"delivery:failed",
|
|
227
|
+
task.taskId,
|
|
228
|
+
"no transport",
|
|
229
|
+
task.channel,
|
|
230
|
+
task.projectId,
|
|
231
|
+
);
|
|
232
|
+
await this.eventProcessor.add(
|
|
233
|
+
buildStreamEvent(
|
|
234
|
+
"notification.failed",
|
|
235
|
+
{
|
|
236
|
+
projectId: task.projectId,
|
|
237
|
+
taskId: task.taskId,
|
|
238
|
+
enrichedEventId: task.enrichedEventId,
|
|
239
|
+
recipientId: task.recipientId,
|
|
240
|
+
channel: task.channel,
|
|
241
|
+
failureReason: "no transport registered for channel",
|
|
242
|
+
failureCode: "no_transport",
|
|
243
|
+
retryable: false,
|
|
244
|
+
attempt,
|
|
245
|
+
templateId: task.templateId,
|
|
246
|
+
workflowInstanceId:
|
|
247
|
+
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
|
|
248
|
+
campaignId: task.campaignId,
|
|
249
|
+
},
|
|
250
|
+
"delivery",
|
|
251
|
+
event.metadata.traceId,
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const limitConfig = transports[0].limits;
|
|
259
|
+
if (limitConfig) {
|
|
260
|
+
const { allowed, retryAfterMs } = await throttleProvider(
|
|
261
|
+
this.redisCli,
|
|
262
|
+
task.channel,
|
|
263
|
+
limitConfig,
|
|
264
|
+
this.logger,
|
|
265
|
+
);
|
|
266
|
+
if (!allowed) {
|
|
267
|
+
task.throttleAttemptCount = (task.throttleAttemptCount ?? 0) + 1;
|
|
268
|
+
const maxAttempts = task.deliveryOptions?.maxAttempts ?? 3;
|
|
269
|
+
|
|
270
|
+
if (task.throttleAttemptCount > maxAttempts) {
|
|
271
|
+
this.logger.warn(
|
|
272
|
+
{ messageId: message.id, taskId: task.taskId, attempts: task.throttleAttemptCount },
|
|
273
|
+
"provider rate limit max attempts exceeded",
|
|
274
|
+
);
|
|
275
|
+
const fallbackTriggered = await fallbackToNextChannel("provider_throttle_exceeded");
|
|
276
|
+
if (!fallbackTriggered) {
|
|
277
|
+
this.globalEmitter.emit(
|
|
278
|
+
"delivery:failed",
|
|
279
|
+
task.taskId,
|
|
280
|
+
"provider throttle exceeded",
|
|
281
|
+
task.channel,
|
|
282
|
+
task.projectId,
|
|
283
|
+
);
|
|
284
|
+
await this.eventProcessor.add(
|
|
285
|
+
buildStreamEvent(
|
|
286
|
+
"notification.failed",
|
|
287
|
+
{
|
|
288
|
+
projectId: task.projectId,
|
|
289
|
+
taskId: task.taskId,
|
|
290
|
+
enrichedEventId: task.enrichedEventId,
|
|
291
|
+
recipientId: task.recipientId,
|
|
292
|
+
channel: task.channel,
|
|
293
|
+
failureReason: "provider rate limit max attempts exceeded",
|
|
294
|
+
failureCode: "provider_throttle_exceeded",
|
|
295
|
+
retryable: false,
|
|
296
|
+
attempt,
|
|
297
|
+
templateId: task.templateId,
|
|
298
|
+
workflowInstanceId:
|
|
299
|
+
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
|
|
300
|
+
campaignId: task.campaignId,
|
|
301
|
+
},
|
|
302
|
+
"delivery",
|
|
303
|
+
event.metadata.traceId,
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const { sql } = await import("drizzle-orm");
|
|
311
|
+
await this.db
|
|
312
|
+
.insert(scheduledPayloads)
|
|
313
|
+
.values({
|
|
314
|
+
taskId: task.taskId,
|
|
315
|
+
payload: task,
|
|
316
|
+
})
|
|
317
|
+
.onConflictDoUpdate({
|
|
318
|
+
target: scheduledPayloads.taskId,
|
|
319
|
+
set: { payload: sql`EXCLUDED.payload` },
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await this.scheduledProducer.publish(
|
|
323
|
+
buildStreamEvent(
|
|
324
|
+
"notification.scheduled",
|
|
325
|
+
{
|
|
326
|
+
projectId: task.projectId,
|
|
327
|
+
enrichedEventId: task.enrichedEventId,
|
|
328
|
+
taskId: task.taskId,
|
|
329
|
+
scheduledAt: new Date(Date.now() + retryAfterMs).toISOString(),
|
|
330
|
+
throttleAttemptCount: task.throttleAttemptCount,
|
|
331
|
+
},
|
|
332
|
+
"delivery",
|
|
333
|
+
`${task.taskId}:throttle:${Date.now()}`,
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const tInsertStart = Date.now();
|
|
342
|
+
const idempotencyKey = task.taskId;
|
|
343
|
+
if (!(await this.idempotency.checkAndMark(idempotencyKey, 60))) {
|
|
344
|
+
this.logger.info(
|
|
345
|
+
{ messageId: message.id, taskId: task.taskId, channel: task.channel, attempt },
|
|
346
|
+
"duplicate delivery — skipping",
|
|
347
|
+
);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
await this.outboxInsertProcessor.add(task);
|
|
353
|
+
const insertTime = Date.now() - tInsertStart;
|
|
354
|
+
|
|
355
|
+
publishPromises.push(
|
|
356
|
+
this.eventProcessor.add(
|
|
357
|
+
buildStreamEvent(
|
|
358
|
+
"notification.dispatched",
|
|
359
|
+
{
|
|
360
|
+
projectId: task.projectId,
|
|
361
|
+
taskId: task.taskId,
|
|
362
|
+
enrichedEventId: task.enrichedEventId,
|
|
363
|
+
recipientId: task.recipientId,
|
|
364
|
+
channel: task.channel,
|
|
365
|
+
templateId: task.templateId,
|
|
366
|
+
attempt,
|
|
367
|
+
workflowInstanceId:
|
|
368
|
+
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
|
|
369
|
+
campaignId: task.campaignId,
|
|
370
|
+
},
|
|
371
|
+
"delivery",
|
|
372
|
+
event.metadata.traceId,
|
|
373
|
+
),
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// For push: we just need to send to the pre-resolved destination,
|
|
378
|
+
// but if it's invalid, we deactivate it.
|
|
379
|
+
if (task.channel === "push") {
|
|
380
|
+
let lastResult: any = { success: false, error: "No transports" };
|
|
381
|
+
|
|
382
|
+
for (const transport of transports) {
|
|
383
|
+
try {
|
|
384
|
+
const tProv = Date.now();
|
|
385
|
+
const breaker = this.getBreaker(`${task.channel}:${transport.constructor.name}`);
|
|
386
|
+
|
|
387
|
+
lastResult = await breaker.execute(async () => {
|
|
388
|
+
const timeoutMs = task.deliveryOptions?.timeoutMs ?? 10_000;
|
|
389
|
+
const controller = new AbortController();
|
|
390
|
+
const timeout = setTimeout(() => {
|
|
391
|
+
controller.abort(new Error(`Transport timeout after ${timeoutMs}ms`));
|
|
392
|
+
}, timeoutMs);
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
(task as any).signal = controller.signal;
|
|
396
|
+
const res: any = await Promise.race([
|
|
397
|
+
transport.send(task),
|
|
398
|
+
new Promise((_, reject) => {
|
|
399
|
+
if (controller.signal.aborted) return reject(controller.signal.reason);
|
|
400
|
+
controller.signal.addEventListener("abort", () =>
|
|
401
|
+
reject(controller.signal.reason),
|
|
402
|
+
);
|
|
403
|
+
}),
|
|
404
|
+
]);
|
|
405
|
+
if (!res.success && !res.invalidToken) {
|
|
406
|
+
throw new Error(res.error ?? "Transport failed");
|
|
407
|
+
}
|
|
408
|
+
return res;
|
|
409
|
+
} finally {
|
|
410
|
+
clearTimeout(timeout);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
(global as any)._telemetry.provider += Date.now() - tProv;
|
|
415
|
+
if (lastResult.success || lastResult.invalidToken) break;
|
|
416
|
+
} catch (err: any) {
|
|
417
|
+
lastResult = { success: false, error: err.message };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (lastResult.invalidToken) {
|
|
422
|
+
await this.contactRepo.deactivate(
|
|
423
|
+
task.projectId,
|
|
424
|
+
task.recipientId,
|
|
425
|
+
task.channel as any,
|
|
426
|
+
task.destination,
|
|
427
|
+
);
|
|
428
|
+
this.logger.info(
|
|
429
|
+
{ channel: task.channel, target: task.destination },
|
|
430
|
+
"deactivated invalid contact target",
|
|
431
|
+
);
|
|
432
|
+
this.globalEmitter.emit(
|
|
433
|
+
"delivery:failed",
|
|
434
|
+
task.taskId,
|
|
435
|
+
"invalidToken",
|
|
436
|
+
task.channel,
|
|
437
|
+
task.projectId,
|
|
438
|
+
);
|
|
439
|
+
metrics.deliveryFailed.inc({ channel: task.channel, reason: "invalid_token" });
|
|
440
|
+
|
|
441
|
+
publishPromises.push(
|
|
442
|
+
this.eventProcessor.add(
|
|
443
|
+
buildStreamEvent(
|
|
444
|
+
"notification.failed",
|
|
445
|
+
{
|
|
446
|
+
projectId: task.projectId,
|
|
447
|
+
taskId: task.taskId,
|
|
448
|
+
enrichedEventId: task.enrichedEventId,
|
|
449
|
+
recipientId: task.recipientId,
|
|
450
|
+
channel: task.channel,
|
|
451
|
+
failureReason: "invalid_token",
|
|
452
|
+
failureCode: "push_failure",
|
|
453
|
+
retryable: false,
|
|
454
|
+
attempt,
|
|
455
|
+
templateId: task.templateId,
|
|
456
|
+
workflowInstanceId:
|
|
457
|
+
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
|
|
458
|
+
campaignId: task.campaignId,
|
|
459
|
+
},
|
|
460
|
+
"delivery",
|
|
461
|
+
event.metadata.traceId,
|
|
462
|
+
),
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const fallbackTriggered = await fallbackToNextChannel("invalid_token");
|
|
467
|
+
if (!fallbackTriggered) {
|
|
468
|
+
throw new NonRetryableError("Push delivery failed: invalid token");
|
|
469
|
+
}
|
|
470
|
+
} else if (lastResult.success) {
|
|
471
|
+
// Record providerMessageId in outbox before publishing event
|
|
472
|
+
const providerMessageId = lastResult.providerMessageId || "push-success";
|
|
473
|
+
|
|
474
|
+
publishPromises.push(
|
|
475
|
+
this.outboxUpdateProcessor.add({
|
|
476
|
+
taskId: task.taskId,
|
|
477
|
+
channel: task.channel,
|
|
478
|
+
destination: task.destination,
|
|
479
|
+
providerMessageId,
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
this.logger.debug(
|
|
484
|
+
{ taskId: task.taskId, messageId: providerMessageId },
|
|
485
|
+
"push delivered",
|
|
486
|
+
);
|
|
487
|
+
this.globalEmitter.emit(
|
|
488
|
+
"delivery:delivered",
|
|
489
|
+
task.taskId,
|
|
490
|
+
providerMessageId,
|
|
491
|
+
task.channel,
|
|
492
|
+
task.projectId,
|
|
493
|
+
);
|
|
494
|
+
metrics.deliverySuccess.inc({ channel: task.channel });
|
|
495
|
+
|
|
496
|
+
publishPromises.push(
|
|
497
|
+
this.eventProcessor.add(
|
|
498
|
+
buildStreamEvent(
|
|
499
|
+
"notification.delivered",
|
|
500
|
+
{
|
|
501
|
+
projectId: task.projectId,
|
|
502
|
+
taskId: task.taskId,
|
|
503
|
+
enrichedEventId: task.enrichedEventId,
|
|
504
|
+
channel: task.channel,
|
|
505
|
+
deliveredAt: new Date().toISOString(),
|
|
506
|
+
providerMessageId,
|
|
507
|
+
templateId: task.templateId,
|
|
508
|
+
workflowInstanceId:
|
|
509
|
+
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
|
|
510
|
+
campaignId: task.campaignId,
|
|
511
|
+
},
|
|
512
|
+
"delivery",
|
|
513
|
+
event.metadata.traceId,
|
|
514
|
+
),
|
|
515
|
+
),
|
|
516
|
+
);
|
|
517
|
+
} else {
|
|
518
|
+
this.logger.warn(
|
|
519
|
+
{ taskId: task.taskId, error: lastResult.error },
|
|
520
|
+
"push delivery failed",
|
|
521
|
+
);
|
|
522
|
+
this.globalEmitter.emit(
|
|
523
|
+
"delivery:failed",
|
|
524
|
+
task.taskId,
|
|
525
|
+
lastResult.error ?? "push delivery failed",
|
|
526
|
+
task.channel,
|
|
527
|
+
task.projectId,
|
|
528
|
+
);
|
|
529
|
+
metrics.deliveryFailed.inc({ channel: task.channel, reason: "push_error" });
|
|
530
|
+
|
|
531
|
+
publishPromises.push(
|
|
532
|
+
this.eventProcessor.add(
|
|
533
|
+
buildStreamEvent(
|
|
534
|
+
"notification.failed",
|
|
535
|
+
{
|
|
536
|
+
projectId: task.projectId,
|
|
537
|
+
taskId: task.taskId,
|
|
538
|
+
enrichedEventId: task.enrichedEventId,
|
|
539
|
+
recipientId: task.recipientId,
|
|
540
|
+
channel: task.channel,
|
|
541
|
+
failureReason: lastResult.error ?? "push delivery failed",
|
|
542
|
+
failureCode: "push_failure",
|
|
543
|
+
retryable: false,
|
|
544
|
+
attempt,
|
|
545
|
+
templateId: task.templateId,
|
|
546
|
+
workflowInstanceId:
|
|
547
|
+
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
|
|
548
|
+
campaignId: task.campaignId,
|
|
549
|
+
},
|
|
550
|
+
"delivery",
|
|
551
|
+
event.metadata.traceId,
|
|
552
|
+
),
|
|
553
|
+
),
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
const fallbackTriggered = await fallbackToNextChannel("push_delivery_failed");
|
|
557
|
+
if (!fallbackTriggered) {
|
|
558
|
+
throw new NonRetryableError(lastResult.error ?? "Push delivery failed");
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
} else {
|
|
562
|
+
// Other channels (email, sms, webhook, whatsapp) use the pre-resolved destination
|
|
563
|
+
let result: any = { success: false, error: "No transports" };
|
|
564
|
+
for (const transport of transports) {
|
|
565
|
+
try {
|
|
566
|
+
const tProv = Date.now();
|
|
567
|
+
const breaker = this.getBreaker(`${task.channel}:${transport.constructor.name}`);
|
|
568
|
+
|
|
569
|
+
result = await breaker.execute(async () => {
|
|
570
|
+
const timeoutMs = task.deliveryOptions?.timeoutMs ?? 10_000;
|
|
571
|
+
const controller = new AbortController();
|
|
572
|
+
const timeout = setTimeout(() => {
|
|
573
|
+
controller.abort(new Error(`Transport timeout after ${timeoutMs}ms`));
|
|
574
|
+
}, timeoutMs);
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
(task as any).signal = controller.signal;
|
|
578
|
+
const res: any = await Promise.race([
|
|
579
|
+
transport.send(task),
|
|
580
|
+
new Promise((_, reject) => {
|
|
581
|
+
if (controller.signal.aborted) return reject(controller.signal.reason);
|
|
582
|
+
controller.signal.addEventListener("abort", () =>
|
|
583
|
+
reject(controller.signal.reason),
|
|
584
|
+
);
|
|
585
|
+
}),
|
|
586
|
+
]);
|
|
587
|
+
if (!res.success) {
|
|
588
|
+
throw new Error(res.error ?? "Transport failed");
|
|
589
|
+
}
|
|
590
|
+
return res;
|
|
591
|
+
} finally {
|
|
592
|
+
clearTimeout(timeout);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
(global as any)._telemetry.provider += Date.now() - tProv;
|
|
597
|
+
if (result.success) break;
|
|
598
|
+
} catch (err: any) {
|
|
599
|
+
result = { success: false, error: err.message };
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (result.success) {
|
|
604
|
+
// Record providerMessageId in outbox before publishing event
|
|
605
|
+
const providerMessageId = result.providerMessageId || "success";
|
|
606
|
+
|
|
607
|
+
publishPromises.push(
|
|
608
|
+
this.outboxUpdateProcessor.add({
|
|
609
|
+
taskId: task.taskId,
|
|
610
|
+
channel: task.channel,
|
|
611
|
+
destination: task.destination,
|
|
612
|
+
providerMessageId,
|
|
613
|
+
}),
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
this.globalEmitter.emit(
|
|
617
|
+
"delivery:delivered",
|
|
618
|
+
task.taskId,
|
|
619
|
+
providerMessageId,
|
|
620
|
+
task.channel,
|
|
621
|
+
task.projectId,
|
|
622
|
+
);
|
|
623
|
+
metrics.deliverySuccess.inc({ channel: task.channel });
|
|
624
|
+
|
|
625
|
+
publishPromises.push(
|
|
626
|
+
this.eventProcessor.add(
|
|
627
|
+
buildStreamEvent(
|
|
628
|
+
"notification.delivered",
|
|
629
|
+
{
|
|
630
|
+
projectId: task.projectId,
|
|
631
|
+
taskId: task.taskId,
|
|
632
|
+
enrichedEventId: task.enrichedEventId,
|
|
633
|
+
channel: task.channel,
|
|
634
|
+
deliveredAt: new Date().toISOString(),
|
|
635
|
+
providerMessageId,
|
|
636
|
+
templateId: task.templateId,
|
|
637
|
+
workflowInstanceId:
|
|
638
|
+
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
|
|
639
|
+
campaignId: task.campaignId,
|
|
640
|
+
},
|
|
641
|
+
"delivery",
|
|
642
|
+
event.metadata.traceId,
|
|
643
|
+
),
|
|
644
|
+
),
|
|
645
|
+
);
|
|
646
|
+
} else {
|
|
647
|
+
publishPromises.push(
|
|
648
|
+
this.eventProcessor.add(
|
|
649
|
+
buildStreamEvent(
|
|
650
|
+
"notification.failed",
|
|
651
|
+
{
|
|
652
|
+
projectId: task.projectId,
|
|
653
|
+
taskId: task.taskId,
|
|
654
|
+
enrichedEventId: task.enrichedEventId,
|
|
655
|
+
channel: task.channel,
|
|
656
|
+
failureReason: result.error ?? "delivery failed completely",
|
|
657
|
+
failureCode: "provider_error",
|
|
658
|
+
retryable: false,
|
|
659
|
+
attempt,
|
|
660
|
+
templateId: task.templateId,
|
|
661
|
+
workflowInstanceId:
|
|
662
|
+
event.metadata.source === "workflow" ? event.metadata.traceId : undefined,
|
|
663
|
+
campaignId: task.campaignId,
|
|
664
|
+
},
|
|
665
|
+
"delivery",
|
|
666
|
+
event.metadata.traceId,
|
|
667
|
+
),
|
|
668
|
+
),
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (!result.success) {
|
|
673
|
+
this.logger.warn(
|
|
674
|
+
{ taskId: task.taskId, channel: task.channel, error: result.error },
|
|
675
|
+
"delivery failed completely across providers",
|
|
676
|
+
);
|
|
677
|
+
this.globalEmitter.emit(
|
|
678
|
+
"delivery:failed",
|
|
679
|
+
task.taskId,
|
|
680
|
+
result.error ?? "delivery failed completely",
|
|
681
|
+
task.channel,
|
|
682
|
+
task.projectId,
|
|
683
|
+
);
|
|
684
|
+
metrics.deliveryFailed.inc({ channel: task.channel, reason: "provider_error" });
|
|
685
|
+
|
|
686
|
+
const fallbackTriggered = await fallbackToNextChannel("all_providers_failed");
|
|
687
|
+
if (!fallbackTriggered) {
|
|
688
|
+
throw new NonRetryableError(result.error ?? "delivery failed");
|
|
689
|
+
}
|
|
690
|
+
} else {
|
|
691
|
+
this.logger.info(
|
|
692
|
+
{ taskId: task.taskId, channel: task.channel, messageId: result.providerMessageId },
|
|
693
|
+
"notification delivered",
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const tFlushStart = Date.now();
|
|
699
|
+
await Promise.all(publishPromises).catch((err) => {
|
|
700
|
+
this.logger.error(
|
|
701
|
+
{ err, taskId: task.taskId },
|
|
702
|
+
"failed to publish post-dispatch events, swallowing error to prevent duplicate delivery",
|
|
703
|
+
);
|
|
704
|
+
});
|
|
705
|
+
const flushTime = Date.now() - tFlushStart;
|
|
706
|
+
|
|
707
|
+
await this.idempotency.markProcessed(idempotencyKey);
|
|
708
|
+
|
|
709
|
+
const t = ((global as any)._telemetry = (global as any)._telemetry || {
|
|
710
|
+
count: 0,
|
|
711
|
+
insert: 0,
|
|
712
|
+
provider: 0,
|
|
713
|
+
flush: 0,
|
|
714
|
+
ack: 0,
|
|
715
|
+
});
|
|
716
|
+
t.count++;
|
|
717
|
+
t.insert += insertTime;
|
|
718
|
+
t.flush += flushTime;
|
|
719
|
+
|
|
720
|
+
if (t.count % 1000 === 0) {
|
|
721
|
+
this.logger.debug(
|
|
722
|
+
`[Metrics 1000 msgs] Dequeue: ${t.dequeue / 1000}ms, DB Insert: ${t.insert / 1000}ms, Provider: ${t.provider / 1000}ms, Wait for Flush: ${t.flush / 1000}ms, Ack: ${t.ack / 1000}ms | DB Update (avg per flush): ${t.dbupdate / Math.max(1, t.flushCount)}ms`,
|
|
723
|
+
);
|
|
724
|
+
t.count = 0;
|
|
725
|
+
t.insert = 0;
|
|
726
|
+
t.provider = 0;
|
|
727
|
+
t.flush = 0;
|
|
728
|
+
t.dequeue = 0;
|
|
729
|
+
t.ack = 0;
|
|
730
|
+
t.dbupdate = 0;
|
|
731
|
+
t.flushCount = 0;
|
|
732
|
+
}
|
|
733
|
+
} catch (err) {
|
|
734
|
+
await this.idempotency.unmark(idempotencyKey).catch(() => {});
|
|
735
|
+
throw err;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export async function startDeliveryWorker() {
|
|
741
|
+
logger = createLogger({ name: "delivery", level: config.LOG_LEVEL });
|
|
742
|
+
|
|
743
|
+
redis = new RedisClient({ url: config.REDIS_URL, name: "delivery", logger });
|
|
744
|
+
const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: "delivery", logger });
|
|
745
|
+
sql = dbData.sql;
|
|
746
|
+
db = dbData.db;
|
|
747
|
+
consumer = new StreamConsumer({
|
|
748
|
+
redis: redis.native,
|
|
749
|
+
stream: OUTBOUND_STREAMS as unknown as StreamName[],
|
|
750
|
+
group: CONSUMER_GROUPS.DELIVERY,
|
|
751
|
+
consumer: `delivery-${process.pid}`,
|
|
752
|
+
dlqStream: STREAMS.DEAD_LETTER,
|
|
753
|
+
batchSize: config.WORKER_CONCURRENCY,
|
|
754
|
+
logger,
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
scheduledProducer = new StreamProducer({
|
|
758
|
+
redis: redis.native,
|
|
759
|
+
stream: STREAMS.SCHEDULED,
|
|
760
|
+
logger,
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
pendingScanner = new PendingMessageScanner({
|
|
764
|
+
redis: redis.native,
|
|
765
|
+
stream: OUTBOUND_STREAMS as unknown as StreamName[],
|
|
766
|
+
group: CONSUMER_GROUPS.DELIVERY,
|
|
767
|
+
consumer: `delivery-${process.pid}`,
|
|
768
|
+
logger,
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
enrichedProducers = {
|
|
772
|
+
critical: new StreamProducer({
|
|
773
|
+
redis: redis.native,
|
|
774
|
+
stream: STREAMS.ENRICHED_CRITICAL,
|
|
775
|
+
logger,
|
|
776
|
+
}),
|
|
777
|
+
normal: new StreamProducer({ redis: redis.native, stream: STREAMS.ENRICHED_NORMAL, logger }),
|
|
778
|
+
low: new StreamProducer({ redis: redis.native, stream: STREAMS.ENRICHED_LOW, logger }),
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const contactRepo = new ContactRepository(db);
|
|
782
|
+
|
|
783
|
+
const idempotency = new IdempotencyGuard({
|
|
784
|
+
redis: redis.native,
|
|
785
|
+
keyPrefix: "notif:processed:delivery",
|
|
786
|
+
ttlSeconds: 86_400,
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// ─── Stage 3: Delivery Worker ───────────────────────────────────────────────
|
|
790
|
+
//
|
|
791
|
+
// Pipeline:
|
|
792
|
+
// 1. Parse payload as notification.dispatched
|
|
793
|
+
// 2. Resolve active device tokens from DB
|
|
794
|
+
// 3. Send via registered transport
|
|
795
|
+
// 4. Deactivate invalid tokens in DB
|
|
796
|
+
|
|
797
|
+
const eventsProducer = new StreamProducer({
|
|
798
|
+
redis: redis.native,
|
|
799
|
+
stream: STREAMS.EVENTS_INBOUND,
|
|
800
|
+
logger,
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
worker = new DeliveryWorker({
|
|
804
|
+
consumer,
|
|
805
|
+
pendingScanner,
|
|
806
|
+
logger,
|
|
807
|
+
concurrency: config.WORKER_CONCURRENCY,
|
|
808
|
+
transportRegistry,
|
|
809
|
+
idempotency,
|
|
810
|
+
redis: redis.native,
|
|
811
|
+
scheduledProducer,
|
|
812
|
+
enrichedProducers,
|
|
813
|
+
contactRepo,
|
|
814
|
+
eventsProducer,
|
|
815
|
+
globalEmitter,
|
|
816
|
+
db,
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// ─── Health check interval ──────────────────────────────────────────────────
|
|
820
|
+
|
|
821
|
+
healthInterval = startHealthReporter("delivery", worker, redis, logger);
|
|
822
|
+
|
|
823
|
+
logger.info(
|
|
824
|
+
{ env: config.NODE_ENV, channels: transportRegistry.registeredChannels() },
|
|
825
|
+
"delivery starting",
|
|
826
|
+
);
|
|
827
|
+
await worker.start();
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ─── Shutdown ──────────────────────────────────────────────────────────────
|
|
831
|
+
|
|
832
|
+
export async function stopDeliveryWorker(): Promise<void> {
|
|
833
|
+
logger?.info("shutdown initiated");
|
|
834
|
+
if (healthInterval) {
|
|
835
|
+
clearInterval(healthInterval);
|
|
836
|
+
healthInterval = null;
|
|
837
|
+
}
|
|
838
|
+
if (worker) await worker.stop();
|
|
839
|
+
if (sql) await sql.end();
|
|
840
|
+
if (redis) await redis.disconnect();
|
|
841
|
+
logger?.info("delivery stopped");
|
|
842
|
+
}
|