@taicode/common-server 1.0.10 → 1.0.12

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.
Files changed (47) hide show
  1. package/output/redis-queue/batch-consumer.d.ts +107 -0
  2. package/output/redis-queue/batch-consumer.d.ts.map +1 -0
  3. package/output/redis-queue/batch-consumer.js +492 -0
  4. package/output/redis-queue/batch-consumer.test.d.ts +2 -0
  5. package/output/redis-queue/batch-consumer.test.d.ts.map +1 -0
  6. package/output/redis-queue/batch-consumer.test.js +216 -0
  7. package/output/redis-queue/batch-redis-queue.d.ts +26 -26
  8. package/output/redis-queue/batch-redis-queue.d.ts.map +1 -1
  9. package/output/redis-queue/batch-redis-queue.js +104 -101
  10. package/output/redis-queue/batch-redis-queue.test.js +96 -251
  11. package/output/redis-queue/index.d.ts +5 -4
  12. package/output/redis-queue/index.d.ts.map +1 -1
  13. package/output/redis-queue/index.js +4 -2
  14. package/output/redis-queue/redis-batch-consumer.d.ts +85 -0
  15. package/output/redis-queue/redis-batch-consumer.d.ts.map +1 -0
  16. package/output/redis-queue/redis-batch-consumer.js +360 -0
  17. package/output/redis-queue/redis-batch-consumer.test.d.ts +2 -0
  18. package/output/redis-queue/redis-batch-consumer.test.d.ts.map +1 -0
  19. package/output/redis-queue/redis-batch-consumer.test.js +265 -0
  20. package/output/redis-queue/redis-queue-common.d.ts +61 -0
  21. package/output/redis-queue/redis-queue-common.d.ts.map +1 -0
  22. package/output/redis-queue/redis-queue-common.js +222 -0
  23. package/output/redis-queue/redis-queue-common.test.d.ts +2 -0
  24. package/output/redis-queue/redis-queue-common.test.d.ts.map +1 -0
  25. package/output/redis-queue/redis-queue-common.test.js +623 -0
  26. package/output/redis-queue/redis-queue-consumer.d.ts +102 -0
  27. package/output/redis-queue/redis-queue-consumer.d.ts.map +1 -0
  28. package/output/redis-queue/redis-queue-consumer.js +461 -0
  29. package/output/redis-queue/redis-queue-consumer.test.d.ts +2 -0
  30. package/output/redis-queue/redis-queue-consumer.test.d.ts.map +1 -0
  31. package/output/redis-queue/redis-queue-consumer.test.js +242 -0
  32. package/output/redis-queue/redis-queue-provider.d.ts +57 -0
  33. package/output/redis-queue/redis-queue-provider.d.ts.map +1 -0
  34. package/output/redis-queue/redis-queue-provider.js +188 -0
  35. package/output/redis-queue/redis-queue-provider.test.d.ts +2 -0
  36. package/output/redis-queue/redis-queue-provider.test.d.ts.map +1 -0
  37. package/output/redis-queue/redis-queue-provider.test.js +114 -0
  38. package/output/redis-queue/redis-queue.d.ts +26 -25
  39. package/output/redis-queue/redis-queue.d.ts.map +1 -1
  40. package/output/redis-queue/redis-queue.js +42 -35
  41. package/output/redis-queue/redis-queue.test.js +96 -698
  42. package/output/redis-queue/registry.d.ts +57 -0
  43. package/output/redis-queue/registry.d.ts.map +1 -0
  44. package/output/redis-queue/registry.js +30 -0
  45. package/output/redis-queue/types.d.ts +42 -13
  46. package/output/redis-queue/types.d.ts.map +1 -1
  47. package/package.json +1 -1
@@ -0,0 +1,102 @@
1
+ import type { RedisQueueConsumerConfig, TaskData, QueueStats } from './types';
2
+ /**
3
+ * Redis 队列消费者
4
+ *
5
+ * 只负责消费任务,不包含入队逻辑
6
+ *
7
+ * @template T 任务数据类型
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * interface EmailTask {
12
+ * to: string
13
+ * subject: string
14
+ * }
15
+ *
16
+ * const consumer = new RedisQueueConsumer<EmailTask>({
17
+ * redisUrl: 'redis://localhost:6379',
18
+ * queueKey: 'email-queue',
19
+ * concurrency: 5,
20
+ * handler: async (data) => {
21
+ * await sendEmail(data.to, data.subject)
22
+ * return catchIt(() => {})
23
+ * }
24
+ * })
25
+ *
26
+ * await consumer.connect() // 自动开始消费
27
+ *
28
+ * // 获取统计信息
29
+ * const stats = await consumer.statistics()
30
+ *
31
+ * // 停止消费
32
+ * consumer.disconnect()
33
+ * ```
34
+ */
35
+ export declare class RedisQueueConsumer<T extends TaskData = TaskData> {
36
+ private consumerRunning;
37
+ private redis;
38
+ private consumerInterval;
39
+ private recoveryInterval;
40
+ private processingTasks;
41
+ private readonly config;
42
+ private readonly failedQueue;
43
+ private readonly pendingQueue;
44
+ private readonly processingQueue;
45
+ private readonly completedQueue;
46
+ constructor(config: RedisQueueConsumerConfig<string, T>);
47
+ /**
48
+ * 连接 Redis 并自动启动消费者
49
+ */
50
+ connect(): Promise<void>;
51
+ /**
52
+ * 断开 Redis 连接并停止消费者
53
+ */
54
+ disconnect(): void;
55
+ /**
56
+ * 获取任务详情
57
+ */
58
+ private getTask;
59
+ /**
60
+ * 更新任务状态并移动到对应队列(原子操作)
61
+ */
62
+ private applyStatus;
63
+ /**
64
+ * 根据状态获取对应的队列键
65
+ */
66
+ private getQueueByStatus;
67
+ /**
68
+ * 恢复超时的任务
69
+ * 检查 processing 队列中的任务,将超时的任务直接标记为失败
70
+ * 使用 Lua 脚本批量处理以提高性能和原子性
71
+ */
72
+ private recoverStalledTasks;
73
+ /**
74
+ * 处理单个任务
75
+ */
76
+ private processTask;
77
+ /**
78
+ * 启动恢复机制(内部方法,自动调用)
79
+ */
80
+ private startRecovery;
81
+ /**
82
+ * 停止恢复机制(内部方法,自动调用)
83
+ */
84
+ private stopRecovery;
85
+ /**
86
+ * 启动消费者(内部方法,自动调用)
87
+ */
88
+ private startConsumer;
89
+ /**
90
+ * 停止消费者(内部方法,自动调用)
91
+ */
92
+ private stopConsumer;
93
+ /**
94
+ * 获取队列统计信息(O(1) 时间复杂度)
95
+ */
96
+ statistics(): Promise<QueueStats>;
97
+ /**
98
+ * 健康检查
99
+ */
100
+ health(): Promise<boolean>;
101
+ }
102
+ //# sourceMappingURL=redis-queue-consumer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-queue-consumer.d.ts","sourceRoot":"","sources":["../../source/redis-queue/redis-queue-consumer.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,wBAAwB,EAAuB,QAAQ,EAAQ,UAAU,EAAE,MAAM,SAAS,CAAA;AAExG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qBAAa,kBAAkB,CAAC,CAAC,SAAS,QAAQ,GAAG,QAAQ;IAC3D,OAAO,CAAC,eAAe,CAAQ;IAC/B,OAAO,CAAC,KAAK,CAA+B;IAC5C,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,eAAe,CAAI;IAE3B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAOtB;IAGD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAQ;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAQ;IACxC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAQ;gBAE3B,MAAM,EAAE,wBAAwB,CAAC,MAAM,EAAE,CAAC,CAAC;IAgDvD;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAY9B;;OAEG;IACH,UAAU,IAAI,IAAI;IAUlB;;OAEG;YACW,OAAO;IAWrB;;OAEG;YACW,WAAW;IA8BzB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAUxB;;;;OAIG;YACW,mBAAmB;IA2EjC;;OAEG;YACW,WAAW;IA4EzB;;OAEG;IACH,OAAO,CAAC,aAAa;IAoBrB;;OAEG;IACH,OAAO,CAAC,YAAY;IAQpB;;OAEG;IACH,OAAO,CAAC,aAAa;IA4ErB;;OAEG;IACH,OAAO,CAAC,YAAY;IASpB;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,UAAU,CAAC;IAevC;;OAEG;IACG,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;CAMjC"}
@@ -0,0 +1,461 @@
1
+ import { createClient } from 'redis';
2
+ import { catchIt } from '@taicode/common-base';
3
+ /**
4
+ * Redis 队列消费者
5
+ *
6
+ * 只负责消费任务,不包含入队逻辑
7
+ *
8
+ * @template T 任务数据类型
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * interface EmailTask {
13
+ * to: string
14
+ * subject: string
15
+ * }
16
+ *
17
+ * const consumer = new RedisQueueConsumer<EmailTask>({
18
+ * redisUrl: 'redis://localhost:6379',
19
+ * queueKey: 'email-queue',
20
+ * concurrency: 5,
21
+ * handler: async (data) => {
22
+ * await sendEmail(data.to, data.subject)
23
+ * return catchIt(() => {})
24
+ * }
25
+ * })
26
+ *
27
+ * await consumer.connect() // 自动开始消费
28
+ *
29
+ * // 获取统计信息
30
+ * const stats = await consumer.statistics()
31
+ *
32
+ * // 停止消费
33
+ * consumer.disconnect()
34
+ * ```
35
+ */
36
+ export class RedisQueueConsumer {
37
+ consumerRunning = false;
38
+ redis = null;
39
+ consumerInterval = null;
40
+ recoveryInterval = null;
41
+ processingTasks = 0; // 当前正在处理的任务数
42
+ config;
43
+ // 不同状态队列的键名
44
+ failedQueue;
45
+ pendingQueue;
46
+ processingQueue;
47
+ completedQueue;
48
+ constructor(config) {
49
+ // 验证必填参数
50
+ if (!config.redisUrl && !config.redisClient) {
51
+ throw new Error('[RedisQueueConsumer] Either redisUrl or redisClient is required');
52
+ }
53
+ if (config.redisUrl && config.redisClient) {
54
+ throw new Error('[RedisQueueConsumer] Cannot specify both redisUrl and redisClient');
55
+ }
56
+ if (!config.queueKey) {
57
+ throw new Error('[RedisQueueConsumer] queueKey is required');
58
+ }
59
+ if (typeof config.queueKey === 'string' && config.queueKey.length < 6) {
60
+ throw new Error('[RedisQueueConsumer] queueKey must be at least 6 characters long');
61
+ }
62
+ if (!config.handler) {
63
+ throw new Error('[RedisQueueConsumer] handler is required');
64
+ }
65
+ this.config = {
66
+ queueKey: config.queueKey,
67
+ redisUrl: config.redisUrl,
68
+ redisClient: config.redisClient,
69
+ handler: config.handler,
70
+ cleanupDelay: config.cleanupDelay ?? 86400, // 24 hours
71
+ maxRetries: config.maxRetries ?? 3,
72
+ concurrency: config.concurrency ?? 1,
73
+ consumerInterval: config.consumerInterval ?? 1000,
74
+ processingTimeout: config.processingTimeout ?? 60000, // 60 seconds
75
+ };
76
+ // 初始化不同状态队列的键名
77
+ this.failedQueue = `${config.queueKey}:failed`;
78
+ this.pendingQueue = `${config.queueKey}:pending`;
79
+ this.completedQueue = `${config.queueKey}:completed`;
80
+ this.processingQueue = `${config.queueKey}:processing`;
81
+ // 使用外部客户端或创建新客户端
82
+ if (config.redisClient) {
83
+ this.redis = config.redisClient;
84
+ }
85
+ else {
86
+ this.redis = createClient({ url: this.config.redisUrl });
87
+ // 添加错误处理
88
+ this.redis.on('error', (err) => {
89
+ console.error('[RedisQueueConsumer] Redis Client Error:', err);
90
+ });
91
+ }
92
+ }
93
+ /**
94
+ * 连接 Redis 并自动启动消费者
95
+ */
96
+ async connect() {
97
+ if (this.redis && !this.redis.isOpen) {
98
+ await this.redis.connect().catch((error) => {
99
+ console.error('[RedisQueueConsumer] Failed to connect to Redis:', error);
100
+ throw error;
101
+ });
102
+ // 连接成功后启动消费者和恢复机制
103
+ this.startConsumer();
104
+ this.startRecovery();
105
+ }
106
+ }
107
+ /**
108
+ * 断开 Redis 连接并停止消费者
109
+ */
110
+ disconnect() {
111
+ this.stopConsumer();
112
+ this.stopRecovery();
113
+ if (this.redis && this.redis.isOpen) {
114
+ this.redis.disconnect().catch((error) => {
115
+ console.error('[RedisQueueConsumer] Failed to disconnect:', error);
116
+ });
117
+ }
118
+ }
119
+ /**
120
+ * 获取任务详情
121
+ */
122
+ async getTask(taskId) {
123
+ if (!this.redis)
124
+ return null;
125
+ const taskKey = `${this.config.queueKey}:task:${taskId}`;
126
+ const taskData = await this.redis.get(taskKey);
127
+ if (!taskData)
128
+ return null;
129
+ return JSON.parse(taskData);
130
+ }
131
+ /**
132
+ * 更新任务状态并移动到对应队列(原子操作)
133
+ */
134
+ async applyStatus(taskId, oldStatus, newStatus) {
135
+ if (!this.redis)
136
+ return;
137
+ const task = await this.getTask(taskId);
138
+ if (!task)
139
+ return;
140
+ task.status = newStatus;
141
+ const taskKey = `${this.config.queueKey}:task:${taskId}`;
142
+ const oldQueue = this.getQueueByStatus(oldStatus);
143
+ const newQueue = this.getQueueByStatus(newStatus);
144
+ if (oldQueue !== newQueue) {
145
+ // 使用 Lua 脚本确保原子性:更新任务 + 移动队列
146
+ const script = `
147
+ redis.call('SETEX', KEYS[1], ARGV[1], ARGV[2])
148
+ redis.call('LREM', KEYS[2], 0, ARGV[3])
149
+ redis.call('RPUSH', KEYS[3], ARGV[3])
150
+ return 1
151
+ `;
152
+ await this.redis.eval(script, {
153
+ keys: [taskKey, oldQueue, newQueue],
154
+ arguments: [this.config.cleanupDelay.toString(), JSON.stringify(task), taskId],
155
+ });
156
+ }
157
+ else {
158
+ // 只更新任务数据
159
+ await this.redis.setEx(taskKey, this.config.cleanupDelay, JSON.stringify(task));
160
+ }
161
+ }
162
+ /**
163
+ * 根据状态获取对应的队列键
164
+ */
165
+ getQueueByStatus(status) {
166
+ switch (status) {
167
+ case 'pending': return this.pendingQueue;
168
+ case 'processing': return this.processingQueue;
169
+ case 'completed': return this.completedQueue;
170
+ case 'failed': return this.failedQueue;
171
+ default: return this.pendingQueue;
172
+ }
173
+ }
174
+ /**
175
+ * 恢复超时的任务
176
+ * 检查 processing 队列中的任务,将超时的任务直接标记为失败
177
+ * 使用 Lua 脚本批量处理以提高性能和原子性
178
+ */
179
+ async recoverStalledTasks() {
180
+ if (!this.redis)
181
+ return;
182
+ try {
183
+ const processingTaskIds = await this.redis.lRange(this.processingQueue, 0, -1);
184
+ if (processingTaskIds.length === 0) {
185
+ return;
186
+ }
187
+ // 使用 Lua 脚本批量恢复超时任务
188
+ const batchRecoveryScript = `
189
+ local processingQueue = KEYS[1]
190
+ local pendingQueue = KEYS[2]
191
+ local failedQueue = KEYS[3]
192
+ local queueKeyPrefix = ARGV[1]
193
+ local ttl = tonumber(ARGV[2])
194
+ local processingTimeout = tonumber(ARGV[3])
195
+ local now = tonumber(ARGV[4])
196
+
197
+ local retryCount = 0
198
+ local failedCount = 0
199
+ local cleanupCount = 0
200
+
201
+ -- ARGV[5], ARGV[6], ARGV[7]... 是 taskId
202
+ for i = 5, #ARGV do
203
+ local taskId = ARGV[i]
204
+ local taskKey = queueKeyPrefix .. ':task:' .. taskId
205
+ local taskData = redis.call('GET', taskKey)
206
+
207
+ if not taskData then
208
+ -- 任务不存在,从队列中清理
209
+ redis.call('LREM', processingQueue, 0, taskId)
210
+ cleanupCount = cleanupCount + 1
211
+ else
212
+ local task = cjson.decode(taskData)
213
+ local processingTime = now - (task.processingStartTime or now)
214
+
215
+ -- 检查是否超时
216
+ if processingTime > processingTimeout then
217
+ -- 超时直接标记为失败
218
+ task.status = 'failed'
219
+ task.processingStartTime = nil
220
+
221
+ redis.call('SETEX', taskKey, ttl, cjson.encode(task))
222
+ redis.call('LREM', processingQueue, 0, taskId)
223
+ redis.call('RPUSH', failedQueue, taskId)
224
+ failedCount = failedCount + 1
225
+ end
226
+ end
227
+ end
228
+
229
+ return {retryCount, failedCount, cleanupCount}
230
+ `;
231
+ const result = await this.redis.eval(batchRecoveryScript, {
232
+ keys: [this.processingQueue, this.pendingQueue, this.failedQueue],
233
+ arguments: [
234
+ this.config.queueKey,
235
+ this.config.cleanupDelay.toString(),
236
+ this.config.processingTimeout.toString(),
237
+ Date.now().toString(),
238
+ ...processingTaskIds,
239
+ ],
240
+ });
241
+ const [retryCount, failedCount, cleanupCount] = result;
242
+ if (failedCount > 0 || cleanupCount > 0) {
243
+ console.log(`[RedisQueueConsumer] Recovered tasks - timeout failed: ${failedCount}, cleaned: ${cleanupCount}`);
244
+ }
245
+ }
246
+ catch (error) {
247
+ console.error('[RedisQueueConsumer] Failed to recover stalled tasks:', error);
248
+ }
249
+ }
250
+ /**
251
+ * 处理单个任务
252
+ */
253
+ async processTask(taskId) {
254
+ this.processingTasks++;
255
+ try {
256
+ const task = await this.getTask(taskId);
257
+ if (!task) {
258
+ console.warn(`[RedisQueueConsumer] Task not found: ${taskId}`);
259
+ return;
260
+ }
261
+ // 任务应该是 pending 状态(Lua 脚本已确保延迟检查)
262
+ if (task.status !== 'pending') {
263
+ console.log(`[RedisQueueConsumer] Task ${taskId} has invalid status (${task.status}), marking as failed`);
264
+ await this.applyStatus(taskId, task.status, 'failed');
265
+ return;
266
+ }
267
+ try {
268
+ // 任务已在 processing 队列中(由 Lua 脚本完成),只需更新状态和开始时间
269
+ task.status = 'processing';
270
+ task.processingStartTime = Date.now();
271
+ const taskKey = `${this.config.queueKey}:task:${taskId}`;
272
+ await this.redis.setEx(taskKey, this.config.cleanupDelay, JSON.stringify(task));
273
+ // 执行任务处理器
274
+ await this.config.handler(task.data);
275
+ // 更新状态为完成
276
+ await this.applyStatus(taskId, 'processing', 'completed');
277
+ console.log(`[RedisQueueConsumer] Task completed: ${taskId}`);
278
+ }
279
+ catch (error) {
280
+ console.error(`[RedisQueueConsumer] Task failed: ${taskId}`, error);
281
+ // 检查是否需要重试
282
+ if (task.retryCount < task.maxRetries) {
283
+ task.retryCount++;
284
+ task.status = 'pending';
285
+ task.processingStartTime = undefined;
286
+ const taskKey = `${this.config.queueKey}:task:${taskId}`;
287
+ // 使用 Lua 脚本确保原子性
288
+ const script = `
289
+ redis.call('SETEX', KEYS[1], ARGV[1], ARGV[2])
290
+ redis.call('LREM', KEYS[2], 0, ARGV[3])
291
+ redis.call('RPUSH', KEYS[3], ARGV[3])
292
+ return 1
293
+ `;
294
+ await this.redis.eval(script, {
295
+ keys: [taskKey, this.processingQueue, this.pendingQueue],
296
+ arguments: [this.config.cleanupDelay.toString(), JSON.stringify(task), taskId],
297
+ });
298
+ console.log(`[RedisQueueConsumer] Task ${taskId} will retry (${task.retryCount}/${task.maxRetries})`);
299
+ }
300
+ else {
301
+ task.status = 'failed';
302
+ task.processingStartTime = undefined;
303
+ const taskKey = `${this.config.queueKey}:task:${taskId}`;
304
+ // 使用 Lua 脚本确保原子性
305
+ const script = `
306
+ redis.call('SETEX', KEYS[1], ARGV[1], ARGV[2])
307
+ redis.call('LREM', KEYS[2], 0, ARGV[3])
308
+ redis.call('RPUSH', KEYS[3], ARGV[3])
309
+ return 1
310
+ `;
311
+ await this.redis.eval(script, {
312
+ keys: [taskKey, this.processingQueue, this.failedQueue],
313
+ arguments: [this.config.cleanupDelay.toString(), JSON.stringify(task), taskId],
314
+ });
315
+ console.error(`[RedisQueueConsumer] Task ${taskId} failed after ${task.maxRetries} retries`);
316
+ }
317
+ }
318
+ }
319
+ finally {
320
+ this.processingTasks--;
321
+ }
322
+ }
323
+ /**
324
+ * 启动恢复机制(内部方法,自动调用)
325
+ */
326
+ startRecovery() {
327
+ if (this.recoveryInterval || !this.redis) {
328
+ return;
329
+ }
330
+ // 立即执行一次恢复
331
+ this.recoverStalledTasks().catch(error => {
332
+ console.error('[RedisQueueConsumer] Initial recovery error:', error);
333
+ });
334
+ // 定期检查(每 10 秒检查一次)
335
+ this.recoveryInterval = setInterval(() => {
336
+ this.recoverStalledTasks().catch(error => {
337
+ console.error('[RedisQueueConsumer] Recovery error:', error);
338
+ });
339
+ }, 10000);
340
+ console.log('[RedisQueueConsumer] Recovery mechanism started');
341
+ }
342
+ /**
343
+ * 停止恢复机制(内部方法,自动调用)
344
+ */
345
+ stopRecovery() {
346
+ if (this.recoveryInterval) {
347
+ clearInterval(this.recoveryInterval);
348
+ this.recoveryInterval = null;
349
+ console.log('[RedisQueueConsumer] Recovery mechanism stopped');
350
+ }
351
+ }
352
+ /**
353
+ * 启动消费者(内部方法,自动调用)
354
+ */
355
+ startConsumer() {
356
+ if (this.consumerRunning || !this.redis) {
357
+ return;
358
+ }
359
+ this.consumerRunning = true;
360
+ console.log(`[RedisQueueConsumer] Consumer started with concurrency: ${this.config.concurrency}`);
361
+ // Lua 脚本: 原子化从 pending 取出任务并移到 processing(只取已到延迟时间的任务)
362
+ const popAndMoveScript = `
363
+ local pendingQueue = KEYS[1]
364
+ local processingQueue = KEYS[2]
365
+ local queueKeyPrefix = KEYS[3]
366
+ local count = tonumber(ARGV[1])
367
+ local currentTime = tonumber(ARGV[2])
368
+
369
+ local taskIds = {}
370
+ local checkedCount = 0
371
+ local maxCheck = count * 3 -- 最多检查 count*3 个任务,避免无限循环
372
+
373
+ while #taskIds < count and checkedCount < maxCheck do
374
+ local taskId = redis.call('LPOP', pendingQueue)
375
+ if not taskId then
376
+ break
377
+ end
378
+
379
+ checkedCount = checkedCount + 1
380
+
381
+ -- 获取任务详情检查延迟时间
382
+ local taskKey = queueKeyPrefix .. ':task:' .. taskId
383
+ local taskData = redis.call('GET', taskKey)
384
+
385
+ if taskData then
386
+ local task = cjson.decode(taskData)
387
+ local delayUntil = task.delayUntil
388
+
389
+ -- 如果没有延迟或已到延迟时间,则移到 processing
390
+ if not delayUntil or delayUntil <= currentTime then
391
+ redis.call('RPUSH', processingQueue, taskId)
392
+ table.insert(taskIds, taskId)
393
+ else
394
+ -- 未到延迟时间,放回队列尾部
395
+ redis.call('RPUSH', pendingQueue, taskId)
396
+ end
397
+ end
398
+ end
399
+
400
+ return taskIds
401
+ `;
402
+ this.consumerInterval = setInterval(async () => {
403
+ try {
404
+ // 检查当前是否有可用的并发槽位
405
+ const availableSlots = this.config.concurrency - this.processingTasks;
406
+ if (availableSlots <= 0) {
407
+ return; // 已达到并发限制,等待下一次轮询
408
+ }
409
+ // 使用 Lua 脚本原子化取出任务并移到 processing 队列
410
+ const taskIds = await this.redis.eval(popAndMoveScript, {
411
+ keys: [this.pendingQueue, this.processingQueue, this.config.queueKey],
412
+ arguments: [availableSlots.toString(), Date.now().toString()],
413
+ });
414
+ // 并发处理所有任务(不等待完成)
415
+ taskIds.forEach(taskId => {
416
+ this.processTask(taskId).catch(error => {
417
+ console.error(`[RedisQueueConsumer] Unhandled error in processTask: ${taskId}`, error);
418
+ });
419
+ });
420
+ }
421
+ catch (error) {
422
+ console.error('[RedisQueueConsumer] Consumer error:', error);
423
+ }
424
+ }, this.config.consumerInterval);
425
+ }
426
+ /**
427
+ * 停止消费者(内部方法,自动调用)
428
+ */
429
+ stopConsumer() {
430
+ if (this.consumerInterval) {
431
+ clearInterval(this.consumerInterval);
432
+ this.consumerInterval = null;
433
+ }
434
+ this.consumerRunning = false;
435
+ console.log('[RedisQueueConsumer] Consumer stopped');
436
+ }
437
+ /**
438
+ * 获取队列统计信息(O(1) 时间复杂度)
439
+ */
440
+ async statistics() {
441
+ if (!this.redis) {
442
+ return { pending: 0, processing: 0, completed: 0, failed: 0 };
443
+ }
444
+ const [pending, processing, completed, failed] = await Promise.all([
445
+ this.redis.lLen(this.pendingQueue),
446
+ this.redis.lLen(this.processingQueue),
447
+ this.redis.lLen(this.completedQueue),
448
+ this.redis.lLen(this.failedQueue),
449
+ ]);
450
+ return { pending, processing, completed, failed };
451
+ }
452
+ /**
453
+ * 健康检查
454
+ */
455
+ async health() {
456
+ if (!this.redis)
457
+ return false;
458
+ const result = await catchIt(() => this.redis.ping());
459
+ return !result.isError() && result.value === 'PONG';
460
+ }
461
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=redis-queue-consumer.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-queue-consumer.test.d.ts","sourceRoot":"","sources":["../../source/redis-queue/redis-queue-consumer.test.ts"],"names":[],"mappings":""}