@onebun/core 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/package.json +6 -6
- package/src/{application.test.ts → application/application.test.ts} +6 -5
- package/src/{application.ts → application/application.ts} +131 -12
- package/src/application/index.ts +9 -0
- package/src/{multi-service-application.test.ts → application/multi-service-application.test.ts} +2 -1
- package/src/{multi-service-application.ts → application/multi-service-application.ts} +2 -1
- package/src/{multi-service.types.ts → application/multi-service.types.ts} +1 -1
- package/src/{decorators.test.ts → decorators/decorators.test.ts} +2 -1
- package/src/{decorators.ts → decorators/decorators.ts} +3 -2
- package/src/decorators/index.ts +15 -0
- package/src/index.ts +47 -134
- package/src/module/index.ts +12 -0
- package/src/{module.test.ts → module/module.test.ts} +3 -2
- package/src/{module.ts → module/module.ts} +6 -5
- package/src/queue/adapters/index.ts +8 -0
- package/src/queue/adapters/memory.adapter.test.ts +405 -0
- package/src/queue/adapters/memory.adapter.ts +509 -0
- package/src/queue/adapters/redis.adapter.ts +673 -0
- package/src/queue/cron-expression.test.ts +145 -0
- package/src/queue/cron-expression.ts +115 -0
- package/src/queue/cron-parser.test.ts +185 -0
- package/src/queue/cron-parser.ts +287 -0
- package/src/queue/decorators.test.ts +292 -0
- package/src/queue/decorators.ts +493 -0
- package/src/queue/docs-examples.test.ts +449 -0
- package/src/queue/guards.test.ts +309 -0
- package/src/queue/guards.ts +307 -0
- package/src/queue/index.ts +118 -0
- package/src/queue/pattern-matcher.test.ts +191 -0
- package/src/queue/pattern-matcher.ts +252 -0
- package/src/queue/queue.service.ts +421 -0
- package/src/queue/scheduler.test.ts +235 -0
- package/src/queue/scheduler.ts +379 -0
- package/src/queue/types.ts +502 -0
- package/src/redis/index.ts +8 -0
- package/src/{env-resolver.ts → service-client/env-resolver.ts} +1 -1
- package/src/service-client/index.ts +10 -0
- package/src/{service-client.test.ts → service-client/service-client.test.ts} +3 -2
- package/src/{service-client.ts → service-client/service-client.ts} +1 -1
- package/src/{service-definition.test.ts → service-client/service-definition.test.ts} +3 -2
- package/src/{service-definition.ts → service-client/service-definition.ts} +2 -2
- package/src/testing/index.ts +7 -0
- package/src/types.ts +34 -5
- package/src/websocket/index.ts +50 -0
- package/src/{ws-decorators.ts → websocket/ws-decorators.ts} +2 -1
- package/src/{ws-integration.test.ts → websocket/ws-integration.test.ts} +3 -2
- package/src/{ws-service-definition.ts → websocket/ws-service-definition.ts} +2 -1
- package/src/{ws-storage-redis.ts → websocket/ws-storage-redis.ts} +1 -1
- /package/src/{metadata.test.ts → decorators/metadata.test.ts} +0 -0
- /package/src/{metadata.ts → decorators/metadata.ts} +0 -0
- /package/src/{config.service.test.ts → module/config.service.test.ts} +0 -0
- /package/src/{config.service.ts → module/config.service.ts} +0 -0
- /package/src/{controller.test.ts → module/controller.test.ts} +0 -0
- /package/src/{controller.ts → module/controller.ts} +0 -0
- /package/src/{service.test.ts → module/service.test.ts} +0 -0
- /package/src/{service.ts → module/service.ts} +0 -0
- /package/src/{redis-client.ts → redis/redis-client.ts} +0 -0
- /package/src/{shared-redis.ts → redis/shared-redis.ts} +0 -0
- /package/src/{env-resolver.test.ts → service-client/env-resolver.test.ts} +0 -0
- /package/src/{service-client.types.ts → service-client/service-client.types.ts} +0 -0
- /package/src/{test-utils.test.ts → testing/test-utils.test.ts} +0 -0
- /package/src/{test-utils.ts → testing/test-utils.ts} +0 -0
- /package/src/{ws-base-gateway.test.ts → websocket/ws-base-gateway.test.ts} +0 -0
- /package/src/{ws-base-gateway.ts → websocket/ws-base-gateway.ts} +0 -0
- /package/src/{ws-client.test.ts → websocket/ws-client.test.ts} +0 -0
- /package/src/{ws-client.ts → websocket/ws-client.ts} +0 -0
- /package/src/{ws-client.types.ts → websocket/ws-client.types.ts} +0 -0
- /package/src/{ws-decorators.test.ts → websocket/ws-decorators.test.ts} +0 -0
- /package/src/{ws-guards.test.ts → websocket/ws-guards.test.ts} +0 -0
- /package/src/{ws-guards.ts → websocket/ws-guards.ts} +0 -0
- /package/src/{ws-handler.ts → websocket/ws-handler.ts} +0 -0
- /package/src/{ws-pattern-matcher.test.ts → websocket/ws-pattern-matcher.test.ts} +0 -0
- /package/src/{ws-pattern-matcher.ts → websocket/ws-pattern-matcher.ts} +0 -0
- /package/src/{ws-socketio-protocol.test.ts → websocket/ws-socketio-protocol.test.ts} +0 -0
- /package/src/{ws-socketio-protocol.ts → websocket/ws-socketio-protocol.ts} +0 -0
- /package/src/{ws-storage-memory.test.ts → websocket/ws-storage-memory.test.ts} +0 -0
- /package/src/{ws-storage-memory.ts → websocket/ws-storage-memory.ts} +0 -0
- /package/src/{ws-storage.ts → websocket/ws-storage.ts} +0 -0
- /package/src/{ws.types.ts → websocket/ws.types.ts} +0 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis Queue Adapter
|
|
3
|
+
*
|
|
4
|
+
* Queue adapter using Redis for distributed message queuing.
|
|
5
|
+
* Uses SharedRedisProvider by default (like cache and websocket).
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Pub/Sub for real-time message delivery
|
|
9
|
+
* - Lists for persistent queues
|
|
10
|
+
* - Sorted sets for delayed messages and priority queues
|
|
11
|
+
* - Consumer groups for load balancing
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
QueueAdapter,
|
|
16
|
+
QueueAdapterType,
|
|
17
|
+
QueueFeature,
|
|
18
|
+
QueueEvents,
|
|
19
|
+
Message,
|
|
20
|
+
MessageMetadata,
|
|
21
|
+
PublishOptions,
|
|
22
|
+
SubscribeOptions,
|
|
23
|
+
Subscription,
|
|
24
|
+
ScheduledJobOptions,
|
|
25
|
+
ScheduledJobInfo,
|
|
26
|
+
MessageHandler,
|
|
27
|
+
} from '../types';
|
|
28
|
+
|
|
29
|
+
import { RedisClient } from '../../redis/redis-client';
|
|
30
|
+
import { SharedRedisProvider } from '../../redis/shared-redis';
|
|
31
|
+
import { createQueuePatternMatcher, type QueuePatternMatch } from '../pattern-matcher';
|
|
32
|
+
import { QueueScheduler } from '../scheduler';
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Types
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Redis queue adapter options
|
|
40
|
+
*/
|
|
41
|
+
export interface RedisQueueOptions {
|
|
42
|
+
/** Use shared Redis client (default: true) */
|
|
43
|
+
useSharedClient?: boolean;
|
|
44
|
+
|
|
45
|
+
/** Redis URL (only used if useSharedClient is false) */
|
|
46
|
+
url?: string;
|
|
47
|
+
|
|
48
|
+
/** Key prefix for all queue operations */
|
|
49
|
+
keyPrefix?: string;
|
|
50
|
+
|
|
51
|
+
/** Poll interval for delayed messages (ms, default: 100) */
|
|
52
|
+
pollInterval?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RedisSubscriptionEntry {
|
|
56
|
+
pattern: string;
|
|
57
|
+
handler: MessageHandler;
|
|
58
|
+
options?: SubscribeOptions;
|
|
59
|
+
matcher: (topic: string) => QueuePatternMatch;
|
|
60
|
+
paused: boolean;
|
|
61
|
+
consumerGroup?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// Redis Message Implementation
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Redis message implementation
|
|
70
|
+
*/
|
|
71
|
+
class RedisMessage<T> implements Message<T> {
|
|
72
|
+
id: string;
|
|
73
|
+
pattern: string;
|
|
74
|
+
data: T;
|
|
75
|
+
timestamp: number;
|
|
76
|
+
redelivered: boolean;
|
|
77
|
+
metadata: MessageMetadata;
|
|
78
|
+
attempt?: number;
|
|
79
|
+
maxAttempts?: number;
|
|
80
|
+
|
|
81
|
+
private acked = false;
|
|
82
|
+
private nacked = false;
|
|
83
|
+
private onAck?: () => Promise<void>;
|
|
84
|
+
private onNack?: (requeue: boolean) => Promise<void>;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
id: string,
|
|
88
|
+
pattern: string,
|
|
89
|
+
data: T,
|
|
90
|
+
timestamp: number,
|
|
91
|
+
metadata: MessageMetadata,
|
|
92
|
+
options?: {
|
|
93
|
+
redelivered?: boolean;
|
|
94
|
+
attempt?: number;
|
|
95
|
+
maxAttempts?: number;
|
|
96
|
+
onAck?: () => Promise<void>;
|
|
97
|
+
onNack?: (requeue: boolean) => Promise<void>;
|
|
98
|
+
},
|
|
99
|
+
) {
|
|
100
|
+
this.id = id;
|
|
101
|
+
this.pattern = pattern;
|
|
102
|
+
this.data = data;
|
|
103
|
+
this.timestamp = timestamp;
|
|
104
|
+
this.metadata = metadata;
|
|
105
|
+
this.redelivered = options?.redelivered ?? false;
|
|
106
|
+
this.attempt = options?.attempt;
|
|
107
|
+
this.maxAttempts = options?.maxAttempts;
|
|
108
|
+
this.onAck = options?.onAck;
|
|
109
|
+
this.onNack = options?.onNack;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async ack(): Promise<void> {
|
|
113
|
+
if (this.acked || this.nacked) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.acked = true;
|
|
117
|
+
await this.onAck?.();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async nack(requeue = false): Promise<void> {
|
|
121
|
+
if (this.acked || this.nacked) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.nacked = true;
|
|
125
|
+
await this.onNack?.(requeue);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// Redis Subscription Implementation
|
|
131
|
+
// ============================================================================
|
|
132
|
+
|
|
133
|
+
class RedisSubscription implements Subscription {
|
|
134
|
+
private active = true;
|
|
135
|
+
|
|
136
|
+
constructor(
|
|
137
|
+
private readonly entry: RedisSubscriptionEntry,
|
|
138
|
+
private readonly onUnsubscribe: () => Promise<void>,
|
|
139
|
+
) {}
|
|
140
|
+
|
|
141
|
+
async unsubscribe(): Promise<void> {
|
|
142
|
+
this.active = false;
|
|
143
|
+
await this.onUnsubscribe();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
pause(): void {
|
|
147
|
+
this.entry.paused = true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
resume(): void {
|
|
151
|
+
this.entry.paused = false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get pattern(): string {
|
|
155
|
+
return this.entry.pattern;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
get isActive(): boolean {
|
|
159
|
+
return this.active && !this.entry.paused;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// Redis Queue Adapter
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Redis Queue Adapter
|
|
169
|
+
*
|
|
170
|
+
* Uses SharedRedisProvider by default for connection sharing
|
|
171
|
+
* with cache and websocket modules.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* ```typescript
|
|
175
|
+
* // Using shared client (default)
|
|
176
|
+
* const adapter = new RedisQueueAdapter();
|
|
177
|
+
* await adapter.connect();
|
|
178
|
+
*
|
|
179
|
+
* // Using custom connection
|
|
180
|
+
* const adapter = new RedisQueueAdapter({
|
|
181
|
+
* useSharedClient: false,
|
|
182
|
+
* url: 'redis://localhost:6379',
|
|
183
|
+
* keyPrefix: 'myapp:queue:',
|
|
184
|
+
* });
|
|
185
|
+
* await adapter.connect();
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
export class RedisQueueAdapter implements QueueAdapter {
|
|
189
|
+
readonly name = 'redis';
|
|
190
|
+
readonly type: QueueAdapterType = 'redis';
|
|
191
|
+
|
|
192
|
+
private client: RedisClient | null = null;
|
|
193
|
+
private ownsClient = false;
|
|
194
|
+
private connected = false;
|
|
195
|
+
private scheduler: QueueScheduler | null = null;
|
|
196
|
+
private subscriptions: RedisSubscriptionEntry[] = [];
|
|
197
|
+
private messageIdCounter = 0;
|
|
198
|
+
private running = false;
|
|
199
|
+
private delayedInterval?: ReturnType<typeof setInterval>;
|
|
200
|
+
|
|
201
|
+
// Key prefixes
|
|
202
|
+
private keys = {
|
|
203
|
+
delayed: 'queue:delayed',
|
|
204
|
+
priority: 'queue:priority',
|
|
205
|
+
queue: (pattern: string) => `queue:q:${pattern}`,
|
|
206
|
+
channel: (pattern: string) => `queue:ch:${pattern}`,
|
|
207
|
+
processing: (group: string) => `queue:processing:${group}`,
|
|
208
|
+
deadLetter: (pattern: string) => `queue:dlq:${pattern}`,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Event handlers
|
|
212
|
+
private eventHandlers: Map<keyof QueueEvents, Set<(...args: unknown[]) => void>> = new Map();
|
|
213
|
+
|
|
214
|
+
private readonly options: Required<RedisQueueOptions>;
|
|
215
|
+
|
|
216
|
+
constructor(options: RedisQueueOptions = {}) {
|
|
217
|
+
this.options = {
|
|
218
|
+
useSharedClient: options.useSharedClient ?? true,
|
|
219
|
+
url: options.url ?? '',
|
|
220
|
+
keyPrefix: options.keyPrefix ?? 'onebun:',
|
|
221
|
+
pollInterval: options.pollInterval ?? 100,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Update key prefixes
|
|
225
|
+
const prefix = this.options.keyPrefix;
|
|
226
|
+
this.keys = {
|
|
227
|
+
delayed: `${prefix}queue:delayed`,
|
|
228
|
+
priority: `${prefix}queue:priority`,
|
|
229
|
+
queue: (pattern: string) => `${prefix}queue:q:${pattern}`,
|
|
230
|
+
channel: (pattern: string) => `${prefix}queue:ch:${pattern}`,
|
|
231
|
+
processing: (group: string) => `${prefix}queue:processing:${group}`,
|
|
232
|
+
deadLetter: (pattern: string) => `${prefix}queue:dlq:${pattern}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ============================================================================
|
|
237
|
+
// Lifecycle
|
|
238
|
+
// ============================================================================
|
|
239
|
+
|
|
240
|
+
async connect(): Promise<void> {
|
|
241
|
+
if (this.connected) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
if (this.options.useSharedClient) {
|
|
247
|
+
// Use shared client (default)
|
|
248
|
+
this.client = await SharedRedisProvider.getClient();
|
|
249
|
+
this.ownsClient = false;
|
|
250
|
+
} else {
|
|
251
|
+
// Create own client
|
|
252
|
+
if (!this.options.url) {
|
|
253
|
+
throw new Error('Redis URL is required when not using shared client');
|
|
254
|
+
}
|
|
255
|
+
this.client = SharedRedisProvider.createClient({
|
|
256
|
+
url: this.options.url,
|
|
257
|
+
keyPrefix: '', // We handle prefix ourselves
|
|
258
|
+
});
|
|
259
|
+
await this.client.connect();
|
|
260
|
+
this.ownsClient = true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this.connected = true;
|
|
264
|
+
this.running = true;
|
|
265
|
+
this.scheduler = new QueueScheduler(this);
|
|
266
|
+
|
|
267
|
+
// Start delayed message processor
|
|
268
|
+
this.startDelayedProcessor();
|
|
269
|
+
|
|
270
|
+
// Emit ready event
|
|
271
|
+
this.emit('onReady');
|
|
272
|
+
} catch (error) {
|
|
273
|
+
this.emit('onError', error as Error);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async disconnect(): Promise<void> {
|
|
279
|
+
if (!this.connected) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
this.running = false;
|
|
284
|
+
|
|
285
|
+
// Stop scheduler
|
|
286
|
+
if (this.scheduler) {
|
|
287
|
+
this.scheduler.stop();
|
|
288
|
+
this.scheduler = null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Stop delayed processor
|
|
292
|
+
if (this.delayedInterval) {
|
|
293
|
+
clearInterval(this.delayedInterval);
|
|
294
|
+
this.delayedInterval = undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Clear subscriptions
|
|
298
|
+
this.subscriptions = [];
|
|
299
|
+
|
|
300
|
+
// Disconnect client only if we own it
|
|
301
|
+
if (this.ownsClient && this.client) {
|
|
302
|
+
await this.client.disconnect();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.client = null;
|
|
306
|
+
this.connected = false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
isConnected(): boolean {
|
|
310
|
+
return this.connected && (this.client?.isConnected() ?? false);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ============================================================================
|
|
314
|
+
// Publishing
|
|
315
|
+
// ============================================================================
|
|
316
|
+
|
|
317
|
+
async publish<T>(pattern: string, data: T, options?: PublishOptions): Promise<string> {
|
|
318
|
+
this.ensureConnected();
|
|
319
|
+
|
|
320
|
+
const messageId = options?.messageId ?? this.generateMessageId();
|
|
321
|
+
const timestamp = Date.now();
|
|
322
|
+
|
|
323
|
+
const messageData = {
|
|
324
|
+
id: messageId,
|
|
325
|
+
pattern,
|
|
326
|
+
data,
|
|
327
|
+
timestamp,
|
|
328
|
+
metadata: options?.metadata ?? {},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const serialized = JSON.stringify(messageData);
|
|
332
|
+
|
|
333
|
+
if (options?.delay && options.delay > 0) {
|
|
334
|
+
// Delayed message - use sorted set
|
|
335
|
+
const score = timestamp + options.delay;
|
|
336
|
+
await this.client!.raw('ZADD', this.keys.delayed, String(score), serialized);
|
|
337
|
+
} else if (options?.priority && options.priority > 0) {
|
|
338
|
+
// Priority message - use sorted set with negative priority (higher = more important)
|
|
339
|
+
await this.client!.raw('ZADD', this.keys.priority, String(-options.priority), serialized);
|
|
340
|
+
} else {
|
|
341
|
+
// Normal message - push to list and publish to channel
|
|
342
|
+
await this.client!.raw('RPUSH', this.keys.queue(pattern), serialized);
|
|
343
|
+
await this.client!.publish(this.keys.channel(pattern), serialized);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return messageId;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async publishBatch<T>(
|
|
350
|
+
messages: Array<{ pattern: string; data: T; options?: PublishOptions }>,
|
|
351
|
+
): Promise<string[]> {
|
|
352
|
+
const ids: string[] = [];
|
|
353
|
+
|
|
354
|
+
for (const msg of messages) {
|
|
355
|
+
const id = await this.publish(msg.pattern, msg.data, msg.options);
|
|
356
|
+
ids.push(id);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return ids;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ============================================================================
|
|
363
|
+
// Subscribing
|
|
364
|
+
// ============================================================================
|
|
365
|
+
|
|
366
|
+
async subscribe<T>(
|
|
367
|
+
pattern: string,
|
|
368
|
+
handler: MessageHandler<T>,
|
|
369
|
+
options?: SubscribeOptions,
|
|
370
|
+
): Promise<Subscription> {
|
|
371
|
+
this.ensureConnected();
|
|
372
|
+
|
|
373
|
+
const entry: RedisSubscriptionEntry = {
|
|
374
|
+
pattern,
|
|
375
|
+
handler: handler as MessageHandler,
|
|
376
|
+
options,
|
|
377
|
+
matcher: createQueuePatternMatcher(pattern),
|
|
378
|
+
paused: false,
|
|
379
|
+
consumerGroup: options?.group,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
this.subscriptions.push(entry);
|
|
383
|
+
|
|
384
|
+
// Subscribe to Redis pub/sub channel
|
|
385
|
+
await this.client!.subscribe(this.keys.channel(pattern), (message) => {
|
|
386
|
+
if (entry.paused) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const parsed = JSON.parse(message);
|
|
392
|
+
this.processMessage(entry, parsed);
|
|
393
|
+
} catch {
|
|
394
|
+
// Silently ignore message parsing errors
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Also start polling the queue for messages that were published before subscription
|
|
399
|
+
this.startQueuePolling(entry);
|
|
400
|
+
|
|
401
|
+
const subscription = new RedisSubscription(entry, async () => {
|
|
402
|
+
const index = this.subscriptions.indexOf(entry);
|
|
403
|
+
if (index !== -1) {
|
|
404
|
+
this.subscriptions.splice(index, 1);
|
|
405
|
+
}
|
|
406
|
+
await this.client!.unsubscribe(this.keys.channel(pattern));
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return subscription;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ============================================================================
|
|
413
|
+
// Scheduled Jobs
|
|
414
|
+
// ============================================================================
|
|
415
|
+
|
|
416
|
+
async addScheduledJob(name: string, options: ScheduledJobOptions): Promise<void> {
|
|
417
|
+
this.ensureConnected();
|
|
418
|
+
|
|
419
|
+
if (!this.scheduler) {
|
|
420
|
+
throw new Error('Scheduler not initialized');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (options.schedule.cron) {
|
|
424
|
+
this.scheduler.addCronJob(name, options.schedule.cron, options.pattern, () => options.data, {
|
|
425
|
+
metadata: options.metadata,
|
|
426
|
+
overlapStrategy: options.overlapStrategy,
|
|
427
|
+
});
|
|
428
|
+
} else if (options.schedule.every) {
|
|
429
|
+
this.scheduler.addIntervalJob(name, options.schedule.every, options.pattern, () => options.data, {
|
|
430
|
+
metadata: options.metadata,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async removeScheduledJob(name: string): Promise<boolean> {
|
|
436
|
+
if (!this.scheduler) {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return this.scheduler.removeJob(name);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async getScheduledJobs(): Promise<ScheduledJobInfo[]> {
|
|
444
|
+
if (!this.scheduler) {
|
|
445
|
+
return [];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return this.scheduler.getJobs();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ============================================================================
|
|
452
|
+
// Features
|
|
453
|
+
// ============================================================================
|
|
454
|
+
|
|
455
|
+
supports(_feature: QueueFeature): boolean {
|
|
456
|
+
// Redis supports all features
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ============================================================================
|
|
461
|
+
// Events
|
|
462
|
+
// ============================================================================
|
|
463
|
+
|
|
464
|
+
on<E extends keyof QueueEvents>(event: E, handler: NonNullable<QueueEvents[E]>): void {
|
|
465
|
+
if (!this.eventHandlers.has(event)) {
|
|
466
|
+
this.eventHandlers.set(event, new Set());
|
|
467
|
+
}
|
|
468
|
+
this.eventHandlers.get(event)!.add(handler as (...args: unknown[]) => void);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
off<E extends keyof QueueEvents>(event: E, handler: NonNullable<QueueEvents[E]>): void {
|
|
472
|
+
const handlers = this.eventHandlers.get(event);
|
|
473
|
+
if (handlers) {
|
|
474
|
+
handlers.delete(handler as (...args: unknown[]) => void);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ============================================================================
|
|
479
|
+
// Private Methods
|
|
480
|
+
// ============================================================================
|
|
481
|
+
|
|
482
|
+
private ensureConnected(): void {
|
|
483
|
+
if (!this.connected || !this.client) {
|
|
484
|
+
throw new Error('RedisQueueAdapter not connected. Call connect() first.');
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private generateMessageId(): string {
|
|
489
|
+
// eslint-disable-next-line no-magic-numbers
|
|
490
|
+
return `msg-${++this.messageIdCounter}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private emit<E extends keyof QueueEvents>(event: E, ...args: unknown[]): void {
|
|
494
|
+
const handlers = this.eventHandlers.get(event);
|
|
495
|
+
if (handlers) {
|
|
496
|
+
for (const handler of handlers) {
|
|
497
|
+
try {
|
|
498
|
+
handler(...args);
|
|
499
|
+
} catch {
|
|
500
|
+
// Silently ignore event handler errors
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async processMessage(
|
|
507
|
+
entry: RedisSubscriptionEntry,
|
|
508
|
+
messageData: {
|
|
509
|
+
id: string;
|
|
510
|
+
pattern: string;
|
|
511
|
+
data: unknown;
|
|
512
|
+
timestamp: number;
|
|
513
|
+
metadata?: MessageMetadata;
|
|
514
|
+
},
|
|
515
|
+
): Promise<void> {
|
|
516
|
+
// Check if pattern matches
|
|
517
|
+
const match = entry.matcher(messageData.pattern);
|
|
518
|
+
if (!match.matched) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const message = new RedisMessage(
|
|
523
|
+
messageData.id,
|
|
524
|
+
messageData.pattern,
|
|
525
|
+
messageData.data,
|
|
526
|
+
messageData.timestamp,
|
|
527
|
+
messageData.metadata ?? {},
|
|
528
|
+
{
|
|
529
|
+
onAck: async () => {
|
|
530
|
+
// Remove from processing set if using consumer groups
|
|
531
|
+
if (entry.consumerGroup) {
|
|
532
|
+
await this.client!.srem(
|
|
533
|
+
this.keys.processing(entry.consumerGroup),
|
|
534
|
+
JSON.stringify(messageData),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
onNack: async (requeue) => {
|
|
539
|
+
if (requeue) {
|
|
540
|
+
// Re-queue the message
|
|
541
|
+
await this.client!.raw(
|
|
542
|
+
'LPUSH',
|
|
543
|
+
this.keys.queue(messageData.pattern),
|
|
544
|
+
JSON.stringify(messageData),
|
|
545
|
+
);
|
|
546
|
+
} else if (entry.options?.deadLetter) {
|
|
547
|
+
// Move to dead letter queue
|
|
548
|
+
await this.client!.raw(
|
|
549
|
+
'RPUSH',
|
|
550
|
+
this.keys.deadLetter(messageData.pattern),
|
|
551
|
+
JSON.stringify(messageData),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
// Emit received event
|
|
559
|
+
this.emit('onMessageReceived', message);
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
await entry.handler(message);
|
|
563
|
+
|
|
564
|
+
// Auto-ack if not manual mode
|
|
565
|
+
if (entry.options?.ackMode !== 'manual') {
|
|
566
|
+
await message.ack();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Emit processed event
|
|
570
|
+
this.emit('onMessageProcessed', message);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
// Emit failed event
|
|
573
|
+
this.emit('onMessageFailed', message, error as Error);
|
|
574
|
+
|
|
575
|
+
// Auto-nack if not manual mode
|
|
576
|
+
if (entry.options?.ackMode !== 'manual') {
|
|
577
|
+
await message.nack(false);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private startQueuePolling(entry: RedisSubscriptionEntry): void {
|
|
583
|
+
// Poll the queue for existing messages
|
|
584
|
+
const poll = async () => {
|
|
585
|
+
if (!this.running || entry.paused) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
// Get message from queue (LPOP for FIFO)
|
|
591
|
+
const result = await this.client!.raw<string | null>(
|
|
592
|
+
'LPOP',
|
|
593
|
+
this.keys.queue(entry.pattern),
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
if (result) {
|
|
597
|
+
const messageData = JSON.parse(result);
|
|
598
|
+
await this.processMessage(entry, messageData);
|
|
599
|
+
}
|
|
600
|
+
} catch {
|
|
601
|
+
// Silently ignore polling errors
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Continue polling
|
|
605
|
+
if (this.running && this.subscriptions.includes(entry)) {
|
|
606
|
+
setTimeout(poll, this.options.pollInterval);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
poll();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private startDelayedProcessor(): void {
|
|
614
|
+
this.delayedInterval = setInterval(async () => {
|
|
615
|
+
if (!this.running || !this.client) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
const now = Date.now();
|
|
621
|
+
|
|
622
|
+
// Get delayed messages that are ready
|
|
623
|
+
const messages = await this.client.raw<string[]>(
|
|
624
|
+
'ZRANGEBYSCORE',
|
|
625
|
+
this.keys.delayed,
|
|
626
|
+
'0',
|
|
627
|
+
String(now),
|
|
628
|
+
'LIMIT',
|
|
629
|
+
'0',
|
|
630
|
+
'100',
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
if (messages && messages.length > 0) {
|
|
634
|
+
for (const msg of messages) {
|
|
635
|
+
// Remove from delayed set
|
|
636
|
+
await this.client.raw('ZREM', this.keys.delayed, msg);
|
|
637
|
+
|
|
638
|
+
// Parse and publish
|
|
639
|
+
const messageData = JSON.parse(msg);
|
|
640
|
+
await this.client.raw('RPUSH', this.keys.queue(messageData.pattern), msg);
|
|
641
|
+
await this.client.publish(this.keys.channel(messageData.pattern), msg);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Also process priority queue
|
|
646
|
+
const priorityMessages = await this.client.raw<string[]>(
|
|
647
|
+
'ZPOPMIN',
|
|
648
|
+
this.keys.priority,
|
|
649
|
+
'10',
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
if (priorityMessages && priorityMessages.length > 0) {
|
|
653
|
+
// ZPOPMIN returns [member, score, member, score, ...]
|
|
654
|
+
for (let i = 0; i < priorityMessages.length; i += 2) {
|
|
655
|
+
const msg = priorityMessages[i];
|
|
656
|
+
const messageData = JSON.parse(msg);
|
|
657
|
+
await this.client.raw('RPUSH', this.keys.queue(messageData.pattern), msg);
|
|
658
|
+
await this.client.publish(this.keys.channel(messageData.pattern), msg);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} catch {
|
|
662
|
+
// Silently ignore delayed message processing errors
|
|
663
|
+
}
|
|
664
|
+
}, this.options.pollInterval);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Create a Redis queue adapter
|
|
670
|
+
*/
|
|
671
|
+
export function createRedisQueueAdapter(options?: RedisQueueOptions): RedisQueueAdapter {
|
|
672
|
+
return new RedisQueueAdapter(options);
|
|
673
|
+
}
|