notifkit 0.1.2 → 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 +130 -122
- package/dist/index.d.mts +196 -132
- 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 +3 -2
- 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,501 @@
|
|
|
1
|
+
import type { Redis } from "@/index.js";
|
|
2
|
+
import type { Logger } from "@/index.js";
|
|
3
|
+
import {
|
|
4
|
+
StreamEventSchema,
|
|
5
|
+
type StreamEvent,
|
|
6
|
+
type StreamName,
|
|
7
|
+
type ConsumerGroup,
|
|
8
|
+
readBaseConfig,
|
|
9
|
+
STREAMS,
|
|
10
|
+
} from "@/index.js";
|
|
11
|
+
import { metrics } from "@/metrics/index.js";
|
|
12
|
+
|
|
13
|
+
// ─── Types ─────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface StreamMessage {
|
|
16
|
+
id: string;
|
|
17
|
+
event: StreamEvent;
|
|
18
|
+
/** Stream this message was read from. Required to ack/claim against the right one. */
|
|
19
|
+
stream?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PendingEntry {
|
|
23
|
+
id: string;
|
|
24
|
+
consumer: string;
|
|
25
|
+
idleMs: number;
|
|
26
|
+
deliveryCount: number;
|
|
27
|
+
/** Stream this entry is pending on. Stream ids are not unique across streams. */
|
|
28
|
+
stream: StreamName;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Internal helpers ──────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function parseMessage(id: string, fields: string[] | null, logger?: Logger): StreamMessage | null {
|
|
34
|
+
if (!fields) return null;
|
|
35
|
+
|
|
36
|
+
const dataIndex = fields.indexOf("data");
|
|
37
|
+
if (dataIndex === -1) return null;
|
|
38
|
+
|
|
39
|
+
const raw = fields[dataIndex + 1];
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
|
|
42
|
+
let decoded: unknown;
|
|
43
|
+
try {
|
|
44
|
+
decoded = JSON.parse(raw);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
logger?.warn({ id, err }, "failed to parse stream event JSON");
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parsed = StreamEventSchema.safeParse(decoded);
|
|
51
|
+
if (!parsed.success) {
|
|
52
|
+
logger?.warn({ id, error: parsed.error.issues }, "failed to parse stream event");
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { id, event: parsed.data };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── StreamProducer ────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export interface StreamProducerOptions {
|
|
62
|
+
redis: Redis;
|
|
63
|
+
stream: StreamName;
|
|
64
|
+
logger?: Logger;
|
|
65
|
+
maxLen?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class StreamProducer {
|
|
69
|
+
private readonly redis: Redis;
|
|
70
|
+
private readonly stream: StreamName;
|
|
71
|
+
private readonly logger?: Logger;
|
|
72
|
+
private readonly maxLen: number;
|
|
73
|
+
|
|
74
|
+
constructor({ redis, stream, logger, maxLen }: StreamProducerOptions) {
|
|
75
|
+
this.redis = redis;
|
|
76
|
+
this.stream = stream;
|
|
77
|
+
this.logger = logger;
|
|
78
|
+
this.maxLen = maxLen ?? readBaseConfig().QUEUE_MAX_LEN;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async publish(partial: Omit<StreamEvent, "id" | "timestamp">): Promise<string> {
|
|
82
|
+
const event: StreamEvent = {
|
|
83
|
+
...partial,
|
|
84
|
+
id: crypto.randomUUID(),
|
|
85
|
+
timestamp: new Date().toISOString(),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const messageId = await this.redis.xadd(
|
|
89
|
+
this.stream,
|
|
90
|
+
"MAXLEN",
|
|
91
|
+
"~",
|
|
92
|
+
String(this.maxLen),
|
|
93
|
+
"*",
|
|
94
|
+
"data",
|
|
95
|
+
JSON.stringify(event),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (!messageId) throw new Error(`XADD to ${this.stream} returned null`);
|
|
99
|
+
|
|
100
|
+
this.logger?.debug(
|
|
101
|
+
{ stream: this.stream, messageId, eventType: event.type, eventId: event.id },
|
|
102
|
+
"event published",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return messageId;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async monitorMaxLen(stream: string) {
|
|
109
|
+
if (Math.random() < 0.05) {
|
|
110
|
+
// Check ~5% of the time to avoid overhead
|
|
111
|
+
try {
|
|
112
|
+
const len = await this.redis.xlen(stream);
|
|
113
|
+
metrics.queueSize.set({ stream }, len);
|
|
114
|
+
|
|
115
|
+
if (len > this.maxLen * 0.8) {
|
|
116
|
+
this.logger?.warn(
|
|
117
|
+
{ stream, len, maxLen: this.maxLen },
|
|
118
|
+
"stream is nearing MAXLEN limit (80%+)",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const dlqLen = await this.redis.xlen(STREAMS.DEAD_LETTER);
|
|
123
|
+
metrics.queueSize.set({ stream: STREAMS.DEAD_LETTER }, dlqLen);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
this.logger?.debug({ err }, "failed to monitor stream length");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async publishBatch(
|
|
131
|
+
partials: Omit<StreamEvent, "id" | "timestamp">[],
|
|
132
|
+
): Promise<{ messageIds: string[]; eventIds: string[] }> {
|
|
133
|
+
if (partials.length === 0) return { messageIds: [], eventIds: [] };
|
|
134
|
+
|
|
135
|
+
const pipeline = this.redis.pipeline();
|
|
136
|
+
const timestamp = new Date().toISOString();
|
|
137
|
+
|
|
138
|
+
const eventIds: string[] = [];
|
|
139
|
+
|
|
140
|
+
for (const partial of partials) {
|
|
141
|
+
const id = crypto.randomUUID();
|
|
142
|
+
eventIds.push(id);
|
|
143
|
+
const event: StreamEvent = {
|
|
144
|
+
...partial,
|
|
145
|
+
id,
|
|
146
|
+
timestamp,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
pipeline.xadd(
|
|
150
|
+
this.stream,
|
|
151
|
+
"MAXLEN",
|
|
152
|
+
"~",
|
|
153
|
+
String(this.maxLen),
|
|
154
|
+
"*",
|
|
155
|
+
"data",
|
|
156
|
+
JSON.stringify(event),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const results = await pipeline.exec();
|
|
161
|
+
if (!results) throw new Error(`Pipeline execution failed for ${this.stream}`);
|
|
162
|
+
|
|
163
|
+
const messageIds: string[] = [];
|
|
164
|
+
for (let i = 0; i < results.length; i++) {
|
|
165
|
+
const result = results[i];
|
|
166
|
+
if (!result) throw new Error("Pipeline result is undefined");
|
|
167
|
+
const [err, msgId] = result;
|
|
168
|
+
if (err) throw err;
|
|
169
|
+
messageIds.push(msgId as string);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.logger?.debug({ stream: this.stream, count: partials.length }, "batch events published");
|
|
173
|
+
|
|
174
|
+
this.monitorMaxLen(this.stream).catch(() => {});
|
|
175
|
+
|
|
176
|
+
return { messageIds, eventIds };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── StreamConsumer ────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export interface StreamConsumerOptions {
|
|
183
|
+
redis: Redis;
|
|
184
|
+
stream: StreamName | StreamName[];
|
|
185
|
+
group: ConsumerGroup;
|
|
186
|
+
consumer: string;
|
|
187
|
+
dlqStream?: StreamName;
|
|
188
|
+
logger?: Logger;
|
|
189
|
+
batchSize?: number;
|
|
190
|
+
blockMs?: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
type XReadGroupResult = Array<[string, Array<[string, string[] | null]>]> | null;
|
|
194
|
+
|
|
195
|
+
export class StreamConsumer {
|
|
196
|
+
readonly redis: Redis;
|
|
197
|
+
private readonly blockingRedis: Redis;
|
|
198
|
+
private readonly streams: StreamName[];
|
|
199
|
+
private readonly group: ConsumerGroup;
|
|
200
|
+
private readonly consumer: string;
|
|
201
|
+
private readonly dlqStream?: StreamName;
|
|
202
|
+
private readonly logger?: Logger;
|
|
203
|
+
private readonly batchSize: number;
|
|
204
|
+
private readonly blockMs: number;
|
|
205
|
+
private running = false;
|
|
206
|
+
|
|
207
|
+
constructor({
|
|
208
|
+
redis,
|
|
209
|
+
stream,
|
|
210
|
+
group,
|
|
211
|
+
consumer,
|
|
212
|
+
dlqStream,
|
|
213
|
+
logger,
|
|
214
|
+
batchSize = 10,
|
|
215
|
+
blockMs = 5_000,
|
|
216
|
+
}: StreamConsumerOptions) {
|
|
217
|
+
this.redis = redis;
|
|
218
|
+
this.blockingRedis = redis.duplicate();
|
|
219
|
+
this.streams = Array.isArray(stream) ? stream : [stream];
|
|
220
|
+
this.group = group;
|
|
221
|
+
this.consumer = consumer;
|
|
222
|
+
this.dlqStream = dlqStream;
|
|
223
|
+
this.logger = logger;
|
|
224
|
+
this.batchSize = batchSize;
|
|
225
|
+
this.blockMs = blockMs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async ensureGroup(): Promise<void> {
|
|
229
|
+
for (const s of this.streams) {
|
|
230
|
+
try {
|
|
231
|
+
// Start from the beginning so events published before the first worker
|
|
232
|
+
// comes online are not silently skipped. Retention is controlled by the
|
|
233
|
+
// producer's MAXLEN policy rather than consumer-group creation time.
|
|
234
|
+
await this.redis.xgroup("CREATE", s, this.group, "0", "MKSTREAM");
|
|
235
|
+
this.logger?.info({ stream: s, group: this.group }, "consumer group created");
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (err instanceof Error && err.message.includes("BUSYGROUP")) {
|
|
238
|
+
this.logger?.debug({ stream: s, group: this.group }, "consumer group already exists");
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async *readBatch(): AsyncGenerator<StreamMessage[], void, unknown> {
|
|
247
|
+
this.running = true;
|
|
248
|
+
let retryDelay = 1000;
|
|
249
|
+
|
|
250
|
+
while (this.running) {
|
|
251
|
+
try {
|
|
252
|
+
let currentStreams = [...this.streams];
|
|
253
|
+
// Weighted fair queuing: 10% of the time, rotate the priority order
|
|
254
|
+
// to prevent starvation of low priority queues.
|
|
255
|
+
if (currentStreams.length > 1 && Math.random() < 0.1) {
|
|
256
|
+
const offset = Math.floor(Math.random() * (currentStreams.length - 1)) + 1;
|
|
257
|
+
for (let i = 0; i < offset; i++) {
|
|
258
|
+
currentStreams.push(currentStreams.shift()!);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let results;
|
|
263
|
+
let deadConnectionTimer: ReturnType<typeof setTimeout> | undefined;
|
|
264
|
+
try {
|
|
265
|
+
results = (await Promise.race([
|
|
266
|
+
this.blockingRedis.xreadgroup(
|
|
267
|
+
"GROUP",
|
|
268
|
+
this.group,
|
|
269
|
+
this.consumer,
|
|
270
|
+
"COUNT",
|
|
271
|
+
String(this.batchSize),
|
|
272
|
+
"BLOCK",
|
|
273
|
+
String(this.blockMs),
|
|
274
|
+
"STREAMS",
|
|
275
|
+
...currentStreams,
|
|
276
|
+
...currentStreams.map(() => ">"),
|
|
277
|
+
),
|
|
278
|
+
new Promise((_, reject) => {
|
|
279
|
+
deadConnectionTimer = setTimeout(
|
|
280
|
+
() => reject(new Error("XREADGROUP_TIMEOUT_DEAD_CONNECTION")),
|
|
281
|
+
this.blockMs + 5000,
|
|
282
|
+
);
|
|
283
|
+
}),
|
|
284
|
+
])) as XReadGroupResult;
|
|
285
|
+
} catch (err: any) {
|
|
286
|
+
if (err.message === "XREADGROUP_TIMEOUT_DEAD_CONNECTION") {
|
|
287
|
+
this.logger?.warn(
|
|
288
|
+
"XREADGROUP took too long, assuming dead connection. Disconnecting...",
|
|
289
|
+
);
|
|
290
|
+
this.blockingRedis.disconnect();
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
} finally {
|
|
295
|
+
// Whichever side of the race loses stays pending, so the guard timer
|
|
296
|
+
// outlives the read it was guarding. Normally the loop turns over once
|
|
297
|
+
// per `blockMs` and only a couple accumulate — but whenever the read
|
|
298
|
+
// returns straight away, one timer per iteration piles up unbounded.
|
|
299
|
+
clearTimeout(deadConnectionTimer);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
retryDelay = 1000; // reset on success
|
|
303
|
+
|
|
304
|
+
if (!results) continue;
|
|
305
|
+
|
|
306
|
+
const batch: StreamMessage[] = [];
|
|
307
|
+
for (const [streamName, messages] of results) {
|
|
308
|
+
for (const [id, fields] of messages) {
|
|
309
|
+
const msg = parseMessage(id, fields, this.logger);
|
|
310
|
+
if (!msg) {
|
|
311
|
+
await this.redis.xack(streamName, this.group, id);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
// Attach original stream name for dynamic acking
|
|
315
|
+
msg.stream = streamName as StreamName;
|
|
316
|
+
batch.push(msg);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (batch.length > 0) yield batch;
|
|
320
|
+
} catch (err) {
|
|
321
|
+
if (
|
|
322
|
+
!this.running &&
|
|
323
|
+
err instanceof Error &&
|
|
324
|
+
err?.message?.toLowerCase?.().includes("connection is closed")
|
|
325
|
+
) {
|
|
326
|
+
break; // Expected during graceful shutdown
|
|
327
|
+
}
|
|
328
|
+
this.logger?.error({ err }, "error reading from stream");
|
|
329
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
330
|
+
retryDelay = Math.min(retryDelay * 2, 30_000); // Exponential backoff up to 30s
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async ack(messageId: string | string[], stream?: string): Promise<void> {
|
|
336
|
+
const s = stream ?? this.streams[0]!;
|
|
337
|
+
const ids = Array.isArray(messageId) ? messageId : [messageId];
|
|
338
|
+
if (ids.length === 0) return;
|
|
339
|
+
await this.redis.xack(s, this.group, ...ids);
|
|
340
|
+
this.logger?.debug({ stream: s, count: ids.length }, "messages acknowledged");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async nack(messageId: string, event: StreamEvent, stream?: string): Promise<void> {
|
|
344
|
+
const s = stream ?? this.streams[0]!;
|
|
345
|
+
if (this.dlqStream) {
|
|
346
|
+
// Sequential rather than pipelined, and in this order: the ack is what
|
|
347
|
+
// makes the drop final, so it must never run against a DLQ write that
|
|
348
|
+
// did not land. A pipeline is not a transaction — both commands execute
|
|
349
|
+
// regardless — so inspecting its results afterwards would be too late.
|
|
350
|
+
// Throwing here leaves the message pending for the recovery loop, which
|
|
351
|
+
// is the recoverable end of the trade.
|
|
352
|
+
const dlqId = await this.redis.xadd(
|
|
353
|
+
this.dlqStream,
|
|
354
|
+
"*",
|
|
355
|
+
"data",
|
|
356
|
+
JSON.stringify({
|
|
357
|
+
...event,
|
|
358
|
+
dlq: { originalStream: s, ackedAt: new Date().toISOString() },
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
if (!dlqId) {
|
|
363
|
+
throw new Error(`XADD to dead-letter stream ${this.dlqStream} returned null`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await this.redis.xack(s, this.group, messageId);
|
|
367
|
+
this.logger?.warn(
|
|
368
|
+
{ stream: s, dlqStream: this.dlqStream, messageId, eventId: event.id, dlqId },
|
|
369
|
+
"message moved to dead-letter queue and acked",
|
|
370
|
+
);
|
|
371
|
+
} else {
|
|
372
|
+
await this.ack(messageId, s);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async stop(): Promise<void> {
|
|
377
|
+
this.running = false;
|
|
378
|
+
try {
|
|
379
|
+
await this.blockingRedis.quit();
|
|
380
|
+
} catch (err: any) {
|
|
381
|
+
if (!err?.message?.toLowerCase?.().includes("connection is closed")) {
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ─── PendingMessageScanner ─────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
export interface PendingMessageScannerOptions {
|
|
391
|
+
redis: Redis;
|
|
392
|
+
stream: StreamName | StreamName[];
|
|
393
|
+
group: ConsumerGroup;
|
|
394
|
+
consumer: string;
|
|
395
|
+
logger?: Logger;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export class PendingMessageScanner {
|
|
399
|
+
private readonly redis: Redis;
|
|
400
|
+
private readonly streams: StreamName[];
|
|
401
|
+
private readonly group: ConsumerGroup;
|
|
402
|
+
private readonly consumer: string;
|
|
403
|
+
private readonly logger?: Logger;
|
|
404
|
+
|
|
405
|
+
constructor({ redis, stream, group, consumer, logger }: PendingMessageScannerOptions) {
|
|
406
|
+
this.redis = redis;
|
|
407
|
+
this.streams = Array.isArray(stream) ? stream : [stream];
|
|
408
|
+
this.group = group;
|
|
409
|
+
this.consumer = consumer;
|
|
410
|
+
this.logger = logger;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async getPendingCount(): Promise<number> {
|
|
414
|
+
let total = 0;
|
|
415
|
+
for (const s of this.streams) {
|
|
416
|
+
const summary = await this.redis.xpending(s, this.group);
|
|
417
|
+
if (Array.isArray(summary) && summary.length > 0) {
|
|
418
|
+
const count = summary[0];
|
|
419
|
+
if (typeof count === "number") total += count;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return total;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Pending entries for one stream, or across all of them when `stream` is omitted. */
|
|
426
|
+
async getPendingEntries(limit = 100, stream?: StreamName): Promise<PendingEntry[]> {
|
|
427
|
+
const streams = stream ? [stream] : this.streams;
|
|
428
|
+
const allEntries: PendingEntry[] = [];
|
|
429
|
+
|
|
430
|
+
for (const s of streams) {
|
|
431
|
+
const result = await this.redis.xpending(s, this.group, "-", "+", limit);
|
|
432
|
+
if (Array.isArray(result)) {
|
|
433
|
+
for (const item of result) {
|
|
434
|
+
if (Array.isArray(item)) {
|
|
435
|
+
allEntries.push({
|
|
436
|
+
id: item[0],
|
|
437
|
+
consumer: item[1],
|
|
438
|
+
idleMs: item[2],
|
|
439
|
+
deliveryCount: item[3],
|
|
440
|
+
stream: s,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (allEntries.length >= limit) break;
|
|
446
|
+
}
|
|
447
|
+
return allEntries.slice(0, limit);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async autoclaim(minIdleMs: number, limit = 10): Promise<StreamMessage[]> {
|
|
451
|
+
const recovered: StreamMessage[] = [];
|
|
452
|
+
|
|
453
|
+
for (const s of this.streams) {
|
|
454
|
+
if (recovered.length >= limit) break;
|
|
455
|
+
|
|
456
|
+
// Scope the scan to THIS stream. Message ids are `<ms>-<seq>` and are not
|
|
457
|
+
// unique across streams, so claiming an id gathered from another stream
|
|
458
|
+
// can silently claim an unrelated message.
|
|
459
|
+
const pending = await this.getPendingEntries(limit * 2, s);
|
|
460
|
+
const toClaim = pending
|
|
461
|
+
.filter((p) => p.idleMs > minIdleMs * Math.pow(2, p.deliveryCount - 1))
|
|
462
|
+
.slice(0, limit - recovered.length);
|
|
463
|
+
|
|
464
|
+
if (toClaim.length === 0) continue;
|
|
465
|
+
|
|
466
|
+
const ids = toClaim.map((p) => p.id);
|
|
467
|
+
// Let Redis arbitrate rather than claiming unconditionally. Two scanners
|
|
468
|
+
// routinely list the same entry, and with a min-idle of 0 both claims
|
|
469
|
+
// succeed and the message is processed twice; with the threshold applied
|
|
470
|
+
// server-side the loser sees an entry whose idle time the winner has just
|
|
471
|
+
// reset, and gets nothing back. The filter above is stricter than this,
|
|
472
|
+
// so nothing it selected is excluded here for being too fresh.
|
|
473
|
+
const result = (await this.redis.xclaim(
|
|
474
|
+
s,
|
|
475
|
+
this.group,
|
|
476
|
+
this.consumer,
|
|
477
|
+
minIdleMs,
|
|
478
|
+
...ids,
|
|
479
|
+
)) as Array<[string, string[] | null]>;
|
|
480
|
+
|
|
481
|
+
for (const raw of result) {
|
|
482
|
+
if (!raw) continue;
|
|
483
|
+
const [id, fields] = raw;
|
|
484
|
+
const msg = parseMessage(id, fields, this.logger);
|
|
485
|
+
if (msg) {
|
|
486
|
+
msg.stream = s;
|
|
487
|
+
recovered.push(msg);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (recovered.length > 0) {
|
|
493
|
+
this.logger?.info(
|
|
494
|
+
{ group: this.group, count: recovered.length },
|
|
495
|
+
"autoclaimed pending messages",
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return recovered;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { Redis } from "@/index.js";
|
|
2
|
+
import { LRUCache } from "@/shared/index.js";
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
|
|
6
|
+
export interface ThrottleResult {
|
|
7
|
+
allowed: boolean;
|
|
8
|
+
count: number;
|
|
9
|
+
limit: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ─── Per-project overrides ──────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Throttle overrides stored on the project row. `null` on either field means
|
|
16
|
+
* "no override" — fall back to the process-wide default.
|
|
17
|
+
*/
|
|
18
|
+
export interface ProjectThrottleSettings {
|
|
19
|
+
throttleLimit: number | null;
|
|
20
|
+
throttleWindowHours: number | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NO_OVERRIDES: ProjectThrottleSettings = {
|
|
24
|
+
throttleLimit: null,
|
|
25
|
+
throttleWindowHours: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface ProjectSettingsCacheOptions {
|
|
29
|
+
maxSize?: number;
|
|
30
|
+
ttlMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Caches per-project throttle overrides for the engine.
|
|
35
|
+
*
|
|
36
|
+
* The throttle check runs once per notification, so an uncached lookup here
|
|
37
|
+
* would put a Postgres round trip on the hot path. Projects with no overrides
|
|
38
|
+
* are cached as well — the common case must not cost a query per message.
|
|
39
|
+
*
|
|
40
|
+
* The TTL bounds staleness on its own; `invalidate()` exists so a settings
|
|
41
|
+
* change published over pub/sub applies immediately rather than at expiry.
|
|
42
|
+
*/
|
|
43
|
+
export class ProjectSettingsCache {
|
|
44
|
+
private readonly cache: LRUCache<string, ProjectThrottleSettings>;
|
|
45
|
+
/**
|
|
46
|
+
* Lookups already on the wire, keyed by project.
|
|
47
|
+
*
|
|
48
|
+
* The cache only fills once a query has come back, so without this a cold
|
|
49
|
+
* project at the start of a campaign puts one query per in-flight message on
|
|
50
|
+
* Postgres before the first answer lands — exactly when the database is
|
|
51
|
+
* busiest. Followers wait on the leader's promise instead.
|
|
52
|
+
*/
|
|
53
|
+
private readonly inFlight = new Map<string, Promise<ProjectThrottleSettings>>();
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
private readonly load: (projectId: string) => Promise<ProjectThrottleSettings | null>,
|
|
57
|
+
{ maxSize = 1000, ttlMs = 60_000 }: ProjectSettingsCacheOptions = {},
|
|
58
|
+
) {
|
|
59
|
+
this.cache = new LRUCache<string, ProjectThrottleSettings>(maxSize, ttlMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Throws whatever the loader throws. The caller decides whether a settings
|
|
64
|
+
* lookup failure should drop the message or fall back to defaults.
|
|
65
|
+
*/
|
|
66
|
+
async get(projectId: string): Promise<ProjectThrottleSettings> {
|
|
67
|
+
const cached = this.cache.get(projectId);
|
|
68
|
+
if (cached) return cached;
|
|
69
|
+
|
|
70
|
+
const existing = this.inFlight.get(projectId);
|
|
71
|
+
if (existing) return existing;
|
|
72
|
+
|
|
73
|
+
// Assigned before any callback below can run, since `load` cannot settle
|
|
74
|
+
// within this synchronous block.
|
|
75
|
+
let pending!: Promise<ProjectThrottleSettings>;
|
|
76
|
+
pending = this.load(projectId)
|
|
77
|
+
.then((settings) => {
|
|
78
|
+
const resolved = settings ?? NO_OVERRIDES;
|
|
79
|
+
// Only cache while this is still the current lookup: an invalidate()
|
|
80
|
+
// that landed while the query was on the wire means the answer in hand
|
|
81
|
+
// already describes the old settings.
|
|
82
|
+
if (this.inFlight.get(projectId) === pending) {
|
|
83
|
+
this.cache.set(projectId, resolved);
|
|
84
|
+
}
|
|
85
|
+
return resolved;
|
|
86
|
+
})
|
|
87
|
+
.finally(() => {
|
|
88
|
+
// Cleared on failure too, so one bad lookup does not pin every later
|
|
89
|
+
// caller to the same rejection.
|
|
90
|
+
if (this.inFlight.get(projectId) === pending) {
|
|
91
|
+
this.inFlight.delete(projectId);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
this.inFlight.set(projectId, pending);
|
|
96
|
+
return pending;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
invalidate(projectId: string): void {
|
|
100
|
+
this.cache.delete(projectId);
|
|
101
|
+
// A lookup that started before the change would write a stale value on
|
|
102
|
+
// arrival; dropping it here sends the next caller back to the database.
|
|
103
|
+
this.inFlight.delete(projectId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
clear(): void {
|
|
107
|
+
this.cache.clear();
|
|
108
|
+
this.inFlight.clear();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── UserThrottle ──────────────────────────────────────────────────────────
|
|
113
|
+
// True sliding window counter using Redis ZSET: max N sends per window per user.
|
|
114
|
+
|
|
115
|
+
export interface UserThrottleOptions {
|
|
116
|
+
redis: Redis;
|
|
117
|
+
maxPerHour?: number;
|
|
118
|
+
windowHours?: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ThrottleCheckOptions {
|
|
122
|
+
/** Per-project cap for this window. `0` blocks every non-critical send. */
|
|
123
|
+
limit?: number | null;
|
|
124
|
+
/** Per-project window length in hours. */
|
|
125
|
+
windowHours?: number | null;
|
|
126
|
+
/** Future send time. The window is evaluated at that instant, not at now. */
|
|
127
|
+
scheduledAt?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Reject stored values that would make the window meaningless. */
|
|
131
|
+
function positiveOrNull(value: number | null | undefined): number | null {
|
|
132
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A limit of 0 is a legitimate kill switch, so zero is allowed here. */
|
|
136
|
+
function nonNegativeOrNull(value: number | null | undefined): number | null {
|
|
137
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class UserThrottle {
|
|
141
|
+
private readonly redis: Redis;
|
|
142
|
+
private readonly maxPerHour: number;
|
|
143
|
+
private readonly windowHours: number;
|
|
144
|
+
|
|
145
|
+
constructor({ redis, maxPerHour = 3, windowHours = 1 }: UserThrottleOptions) {
|
|
146
|
+
this.redis = redis;
|
|
147
|
+
this.maxPerHour = maxPerHour;
|
|
148
|
+
this.windowHours = windowHours;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param projectId Tenant that owns `userId`. User ids are caller-supplied
|
|
153
|
+
* external ids, so they collide across tenants and MUST be namespaced —
|
|
154
|
+
* otherwise one tenant's traffic throttles another's.
|
|
155
|
+
* @param options Per-project overrides. Values that are absent, null, or
|
|
156
|
+
* nonsensical fall back to this instance's defaults.
|
|
157
|
+
*/
|
|
158
|
+
async check(
|
|
159
|
+
projectId: string,
|
|
160
|
+
userId: string,
|
|
161
|
+
priority?: string,
|
|
162
|
+
options: ThrottleCheckOptions = {},
|
|
163
|
+
): Promise<ThrottleResult> {
|
|
164
|
+
const limit = nonNegativeOrNull(options.limit) ?? this.maxPerHour;
|
|
165
|
+
const windowHours = positiveOrNull(options.windowHours) ?? this.windowHours;
|
|
166
|
+
|
|
167
|
+
if (priority === "critical") {
|
|
168
|
+
return { allowed: true, count: 0, limit };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const windowMs = windowHours * 3600_000;
|
|
172
|
+
const key = `throttle:${projectId}:user:${userId}`;
|
|
173
|
+
const targetTime = options.scheduledAt ? new Date(options.scheduledAt).getTime() : Date.now();
|
|
174
|
+
const windowStart = targetTime - windowMs;
|
|
175
|
+
const memberId = randomUUID();
|
|
176
|
+
|
|
177
|
+
const LUA_THROTTLE = `
|
|
178
|
+
redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", ARGV[1])
|
|
179
|
+
local count = redis.call("ZCARD", KEYS[1])
|
|
180
|
+
if tonumber(count) < tonumber(ARGV[2]) then
|
|
181
|
+
redis.call("ZADD", KEYS[1], tonumber(ARGV[3]), ARGV[4])
|
|
182
|
+
redis.call("EXPIRE", KEYS[1], tonumber(ARGV[5]))
|
|
183
|
+
return tonumber(count) + 1
|
|
184
|
+
end
|
|
185
|
+
return tonumber(count) + 1
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
// The key must outlive the window it is counting. For a future-dated send
|
|
189
|
+
// that means surviving until targetTime plus one more window, so a task
|
|
190
|
+
// scheduled for next week still counts against the right bucket.
|
|
191
|
+
const windowSeconds = Math.ceil(windowMs / 1000);
|
|
192
|
+
const ttlSeconds = Math.max(
|
|
193
|
+
windowSeconds,
|
|
194
|
+
Math.ceil((targetTime - Date.now()) / 1000) + windowSeconds,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const count = (await this.redis.eval(
|
|
198
|
+
LUA_THROTTLE,
|
|
199
|
+
1,
|
|
200
|
+
key,
|
|
201
|
+
windowStart,
|
|
202
|
+
limit,
|
|
203
|
+
targetTime,
|
|
204
|
+
memberId,
|
|
205
|
+
ttlSeconds,
|
|
206
|
+
)) as number;
|
|
207
|
+
|
|
208
|
+
return { allowed: count <= limit, count, limit };
|
|
209
|
+
}
|
|
210
|
+
}
|