@taicode/common-server 1.0.13 → 1.0.15
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/output/redis-queue/index.d.ts +2 -1
- package/output/redis-queue/index.d.ts.map +1 -1
- package/output/redis-queue/index.js +3 -1
- package/output/redis-queue/redis-batch-consumer.d.ts +9 -0
- package/output/redis-queue/redis-batch-consumer.d.ts.map +1 -1
- package/output/redis-queue/redis-batch-consumer.js +63 -0
- package/output/redis-queue/redis-queue-batch-consumer.d.ts +77 -0
- package/output/redis-queue/redis-queue-batch-consumer.d.ts.map +1 -0
- package/output/redis-queue/redis-queue-batch-consumer.js +320 -0
- package/output/redis-queue/redis-queue-batch-consumer.test.d.ts +26 -0
- package/output/redis-queue/redis-queue-batch-consumer.test.d.ts.map +1 -0
- package/output/redis-queue/redis-queue-batch-consumer.test.js +341 -0
- package/output/redis-queue/redis-queue-common.d.ts +1 -1
- package/output/redis-queue/redis-queue-common.d.ts.map +1 -1
- package/output/redis-queue/redis-queue-common.js +8 -8
- package/output/redis-queue/redis-queue-common.test.js +16 -54
- package/output/redis-queue/redis-queue-consumer.d.ts +5 -9
- package/output/redis-queue/redis-queue-consumer.d.ts.map +1 -1
- package/output/redis-queue/redis-queue-consumer.js +80 -69
- package/output/redis-queue/redis-queue-consumer.test.d.ts +20 -1
- package/output/redis-queue/redis-queue-consumer.test.d.ts.map +1 -1
- package/output/redis-queue/redis-queue-consumer.test.js +89 -15
- package/output/redis-queue/redis-queue-provider.d.ts +4 -4
- package/output/redis-queue/redis-queue-provider.d.ts.map +1 -1
- package/output/redis-queue/redis-queue-provider.js +10 -6
- package/output/redis-queue/redis-queue-provider.test.d.ts +23 -2
- package/output/redis-queue/redis-queue-provider.test.d.ts.map +1 -1
- package/output/redis-queue/redis-queue-provider.test.js +73 -38
- package/output/redis-queue/test-helpers.d.ts +112 -0
- package/output/redis-queue/test-helpers.d.ts.map +1 -0
- package/output/redis-queue/test-helpers.js +242 -0
- package/output/redis-queue/test-helpers.test.d.ts +28 -0
- package/output/redis-queue/test-helpers.test.d.ts.map +1 -0
- package/output/redis-queue/test-helpers.test.js +572 -0
- package/output/redis-queue/types.d.ts +0 -7
- package/output/redis-queue/types.d.ts.map +1 -1
- package/package.json +3 -3
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { RedisQueueProvider } from './redis-queue-provider';
|
|
2
2
|
export { RedisQueueConsumer } from './redis-queue-consumer';
|
|
3
|
-
export {
|
|
3
|
+
export { RedisQueueBatchConsumer } from './redis-queue-batch-consumer';
|
|
4
4
|
export type { RedisQueueProviderConfig, RedisQueueConsumerConfig, BatchConsumerConfig } from './types';
|
|
5
5
|
export type { TaskData } from './types';
|
|
6
6
|
export type { RedisQueueRegistry } from './types';
|
|
7
|
+
export * as test from './test-helpers';
|
|
7
8
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../source/redis-queue/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../source/redis-queue/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAA;AAGtE,YAAY,EAAE,wBAAwB,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAA;AAGtG,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAGvC,YAAY,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAGjD,OAAO,KAAK,IAAI,MAAM,gBAAgB,CAAA"}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// 核心类导出
|
|
2
2
|
export { RedisQueueProvider } from './redis-queue-provider';
|
|
3
3
|
export { RedisQueueConsumer } from './redis-queue-consumer';
|
|
4
|
-
export {
|
|
4
|
+
export { RedisQueueBatchConsumer } from './redis-queue-batch-consumer';
|
|
5
|
+
// 测试辅助函数导出
|
|
6
|
+
export * as test from './test-helpers';
|
|
@@ -76,5 +76,14 @@ export declare class RedisBatchConsumer<K extends keyof RedisQueueRegistry> exte
|
|
|
76
76
|
* 健康检查
|
|
77
77
|
*/
|
|
78
78
|
health(): Promise<boolean>;
|
|
79
|
+
/**
|
|
80
|
+
* 立即派发批量任务进行处理(仅供测试工具调用)
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ 此方法为 private,仅通过 test-helpers 暴露给测试代码使用
|
|
83
|
+
*
|
|
84
|
+
* @param taskIds 要立即处理的任务 ID 列表
|
|
85
|
+
* @returns Promise,等待所有任务批次处理完成
|
|
86
|
+
*/
|
|
87
|
+
private dispatch;
|
|
79
88
|
}
|
|
80
89
|
//# sourceMappingURL=redis-batch-consumer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redis-batch-consumer.d.ts","sourceRoot":"","sources":["../../source/redis-queue/redis-batch-consumer.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,KAAK,EAAE,mBAAmB,EAA0B,UAAU,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAE1G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,qBAAa,kBAAkB,CAAC,CAAC,SAAS,MAAM,kBAAkB,CAAE,SAAQ,gBAAgB;IAC1F,OAAO,CAAC,eAAe,CAAQ;IAC/B,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,iBAAiB,CAAI;IAE7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAOtB;gBAEW,MAAM,EAAE,mBAAmB,CAAC,CAAC,CAAC;IA2B1C,SAAS,CAAC,YAAY,IAAI,MAAM;IAIhC;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAO9B;;OAEG;IACH,UAAU,IAAI,IAAI;IAMlB;;OAEG;YACW,YAAY;IA4F1B;;OAEG;IACH,OAAO,CAAC,aAAa;IAoBrB;;OAEG;IACH,OAAO,CAAC,YAAY;IAQpB;;OAEG;IACH,OAAO,CAAC,aAAa;IAyErB;;OAEG;IACH,OAAO,CAAC,YAAY;IASpB;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,UAAU,CAAC;IAevC;;OAEG;IACG,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"redis-batch-consumer.d.ts","sourceRoot":"","sources":["../../source/redis-queue/redis-batch-consumer.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,KAAK,EAAE,mBAAmB,EAA0B,UAAU,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAE1G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,qBAAa,kBAAkB,CAAC,CAAC,SAAS,MAAM,kBAAkB,CAAE,SAAQ,gBAAgB;IAC1F,OAAO,CAAC,eAAe,CAAQ;IAC/B,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,iBAAiB,CAAI;IAE7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAOtB;gBAEW,MAAM,EAAE,mBAAmB,CAAC,CAAC,CAAC;IA2B1C,SAAS,CAAC,YAAY,IAAI,MAAM;IAIhC;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAO9B;;OAEG;IACH,UAAU,IAAI,IAAI;IAMlB;;OAEG;YACW,YAAY;IA4F1B;;OAEG;IACH,OAAO,CAAC,aAAa;IAoBrB;;OAEG;IACH,OAAO,CAAC,YAAY;IAQpB;;OAEG;IACH,OAAO,CAAC,aAAa;IAyErB;;OAEG;IACH,OAAO,CAAC,YAAY;IASpB;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,UAAU,CAAC;IAevC;;OAEG;IACG,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;IAOhC;;;;;;;OAOG;YACW,QAAQ;CA+DvB"}
|
|
@@ -305,4 +305,67 @@ export class RedisBatchConsumer extends RedisQueueCommon {
|
|
|
305
305
|
const result = await catchIt(() => this.redis.ping());
|
|
306
306
|
return !result.isError() && result.value === 'PONG';
|
|
307
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* 立即派发批量任务进行处理(仅供测试工具调用)
|
|
310
|
+
*
|
|
311
|
+
* ⚠️ 此方法为 private,仅通过 test-helpers 暴露给测试代码使用
|
|
312
|
+
*
|
|
313
|
+
* @param taskIds 要立即处理的任务 ID 列表
|
|
314
|
+
* @returns Promise,等待所有任务批次处理完成
|
|
315
|
+
*/
|
|
316
|
+
async dispatch(taskIds) {
|
|
317
|
+
if (!this.redis || !this.redis.isOpen) {
|
|
318
|
+
throw new Error('[RedisBatchConsumer] Redis is not connected');
|
|
319
|
+
}
|
|
320
|
+
if (taskIds.length === 0) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// 使用 Lua 脚本将任务从 pending 移到 processing(忽略延迟时间)
|
|
324
|
+
const moveToProcessingScript = `
|
|
325
|
+
local pendingQueue = KEYS[1]
|
|
326
|
+
local processingQueue = KEYS[2]
|
|
327
|
+
local movedIds = {}
|
|
328
|
+
|
|
329
|
+
-- ARGV[1], ARGV[2], ... 是 taskId
|
|
330
|
+
for i = 1, #ARGV do
|
|
331
|
+
local taskId = ARGV[i]
|
|
332
|
+
local removed = redis.call('LREM', pendingQueue, 0, taskId)
|
|
333
|
+
if removed > 0 then
|
|
334
|
+
redis.call('RPUSH', processingQueue, taskId)
|
|
335
|
+
table.insert(movedIds, taskId)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
return movedIds
|
|
340
|
+
`;
|
|
341
|
+
const movedIds = await this.redis.eval(moveToProcessingScript, {
|
|
342
|
+
keys: [this.pendingQueue, this.processingQueue],
|
|
343
|
+
arguments: taskIds,
|
|
344
|
+
});
|
|
345
|
+
if (movedIds.length === 0) {
|
|
346
|
+
console.warn('[RedisBatchConsumer] No tasks were moved to processing queue');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
// 按照 batchSize 分批处理并等待完成
|
|
350
|
+
const batches = [];
|
|
351
|
+
for (let i = 0; i < movedIds.length; i += this.config.batchSize) {
|
|
352
|
+
batches.push(movedIds.slice(i, i + this.config.batchSize));
|
|
353
|
+
}
|
|
354
|
+
// 串行处理每个批次(遵循批量消费语义)
|
|
355
|
+
for (const batch of batches) {
|
|
356
|
+
await this.processBatch(batch);
|
|
357
|
+
}
|
|
358
|
+
// 检查是否有任务需要重试(已放回 pending 队列)
|
|
359
|
+
const retryIds = [];
|
|
360
|
+
for (const taskId of movedIds) {
|
|
361
|
+
const task = await this.getTask(taskId);
|
|
362
|
+
if (task && task.status === 'pending' && task.retryCount > 0) {
|
|
363
|
+
retryIds.push(taskId);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// 递归处理重试任务
|
|
367
|
+
if (retryIds.length > 0) {
|
|
368
|
+
await this.dispatch(retryIds);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
308
371
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { RedisQueueCommon } from './redis-queue-common';
|
|
2
|
+
import type { BatchConsumerConfig, QueueStats, RedisQueueRegistry } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Redis 批量队列消费者
|
|
5
|
+
*
|
|
6
|
+
* 批量消费任务,每次处理多条
|
|
7
|
+
*
|
|
8
|
+
* @template K 队列键名类型
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const consumer = new RedisQueueBatchConsumer('email-queue', {
|
|
13
|
+
* redisUrl: 'redis://localhost:6379',
|
|
14
|
+
* batchSize: 50,
|
|
15
|
+
* handler: async (dataList) => {
|
|
16
|
+
* await sendEmailsBatch(dataList.map(d => d.to))
|
|
17
|
+
* return catchIt(() => {})
|
|
18
|
+
* }
|
|
19
|
+
* })
|
|
20
|
+
*
|
|
21
|
+
* await consumer.connect() // 自动开始消费
|
|
22
|
+
*
|
|
23
|
+
* // 获取统计信息
|
|
24
|
+
* const stats = await consumer.statistics()
|
|
25
|
+
*
|
|
26
|
+
* // 停止消费
|
|
27
|
+
* consumer.disconnect()
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare class RedisQueueBatchConsumer<K extends keyof RedisQueueRegistry> extends RedisQueueCommon {
|
|
31
|
+
private consumerRunning;
|
|
32
|
+
private consumerInterval;
|
|
33
|
+
private recoveryInterval;
|
|
34
|
+
private processingBatches;
|
|
35
|
+
private processingPromises;
|
|
36
|
+
private readonly config;
|
|
37
|
+
constructor(queueKey: K, config: BatchConsumerConfig<K>);
|
|
38
|
+
protected getLogPrefix(): string;
|
|
39
|
+
/**
|
|
40
|
+
* 连接 Redis 并自动启动消费者
|
|
41
|
+
*/
|
|
42
|
+
connect(): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* 断开 Redis 连接并停止消费者
|
|
45
|
+
* 会等待所有正在处理的批次完成后再断开连接
|
|
46
|
+
*/
|
|
47
|
+
disconnect(): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* 批量处理任务
|
|
50
|
+
*/
|
|
51
|
+
private processBatch;
|
|
52
|
+
/**
|
|
53
|
+
* 启动恢复机制
|
|
54
|
+
*/
|
|
55
|
+
private startRecovery;
|
|
56
|
+
/**
|
|
57
|
+
* 停止恢复机制
|
|
58
|
+
*/
|
|
59
|
+
private stopRecovery;
|
|
60
|
+
/**
|
|
61
|
+
* 启动消费者
|
|
62
|
+
*/
|
|
63
|
+
private startConsumer;
|
|
64
|
+
/**
|
|
65
|
+
* 停止消费者
|
|
66
|
+
*/
|
|
67
|
+
private stopConsumer;
|
|
68
|
+
/**
|
|
69
|
+
* 获取队列统计信息(O(1) 时间复杂度)
|
|
70
|
+
*/
|
|
71
|
+
statistics(): Promise<QueueStats>;
|
|
72
|
+
/**
|
|
73
|
+
* 健康检查
|
|
74
|
+
*/
|
|
75
|
+
health(): Promise<boolean>;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=redis-queue-batch-consumer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis-queue-batch-consumer.d.ts","sourceRoot":"","sources":["../../source/redis-queue/redis-queue-batch-consumer.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,KAAK,EAAE,mBAAmB,EAA0B,UAAU,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAE1G;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,uBAAuB,CAAC,CAAC,SAAS,MAAM,kBAAkB,CAAE,SAAQ,gBAAgB;IAC/F,OAAO,CAAC,eAAe,CAAQ;IAC/B,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,iBAAiB,CAAI;IAC7B,OAAO,CAAC,kBAAkB,CAA2B;IAErD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAOtB;gBAEW,QAAQ,EAAE,CAAC,EAAE,MAAM,EAAE,mBAAmB,CAAC,CAAC,CAAC;IAuBvD,SAAS,CAAC,YAAY,IAAI,MAAM;IAIhC;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAO9B;;;OAGG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAgBjC;;OAEG;YACW,YAAY;IA2G1B;;OAEG;IACH,OAAO,CAAC,aAAa;IAoBrB;;OAEG;IACH,OAAO,CAAC,YAAY;IAQpB;;OAEG;IACH,OAAO,CAAC,aAAa;IAyErB;;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,320 @@
|
|
|
1
|
+
import { catchIt } from '@taicode/common-base';
|
|
2
|
+
import { RedisQueueCommon } from './redis-queue-common';
|
|
3
|
+
/**
|
|
4
|
+
* Redis 批量队列消费者
|
|
5
|
+
*
|
|
6
|
+
* 批量消费任务,每次处理多条
|
|
7
|
+
*
|
|
8
|
+
* @template K 队列键名类型
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const consumer = new RedisQueueBatchConsumer('email-queue', {
|
|
13
|
+
* redisUrl: 'redis://localhost:6379',
|
|
14
|
+
* batchSize: 50,
|
|
15
|
+
* handler: async (dataList) => {
|
|
16
|
+
* await sendEmailsBatch(dataList.map(d => d.to))
|
|
17
|
+
* return catchIt(() => {})
|
|
18
|
+
* }
|
|
19
|
+
* })
|
|
20
|
+
*
|
|
21
|
+
* await consumer.connect() // 自动开始消费
|
|
22
|
+
*
|
|
23
|
+
* // 获取统计信息
|
|
24
|
+
* const stats = await consumer.statistics()
|
|
25
|
+
*
|
|
26
|
+
* // 停止消费
|
|
27
|
+
* consumer.disconnect()
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export class RedisQueueBatchConsumer extends RedisQueueCommon {
|
|
31
|
+
consumerRunning = false;
|
|
32
|
+
consumerInterval = null;
|
|
33
|
+
recoveryInterval = null;
|
|
34
|
+
processingBatches = 0; // 当前正在处理的批次数
|
|
35
|
+
processingPromises = new Set(); // 跟踪所有正在处理的批次
|
|
36
|
+
config;
|
|
37
|
+
constructor(queueKey, config) {
|
|
38
|
+
// 验证必填参数
|
|
39
|
+
if (!config.handler) {
|
|
40
|
+
throw new Error('[RedisQueueBatchConsumer] handler is required');
|
|
41
|
+
}
|
|
42
|
+
// 调用父类构造函数
|
|
43
|
+
super(queueKey, {
|
|
44
|
+
redisUrl: config.redisUrl,
|
|
45
|
+
redisClient: config.redisClient,
|
|
46
|
+
cleanupDelay: config.cleanupDelay ?? 86400,
|
|
47
|
+
});
|
|
48
|
+
this.config = {
|
|
49
|
+
handler: config.handler,
|
|
50
|
+
batchSize: config.batchSize ?? 10,
|
|
51
|
+
maxRetries: config.maxRetries ?? 3,
|
|
52
|
+
concurrency: config.concurrency ?? 1,
|
|
53
|
+
consumerInterval: config.consumerInterval ?? 1000,
|
|
54
|
+
processingTimeout: config.processingTimeout ?? 60000, // 60 seconds
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
getLogPrefix() {
|
|
58
|
+
return 'RedisQueueBatchConsumer';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 连接 Redis 并自动启动消费者
|
|
62
|
+
*/
|
|
63
|
+
async connect() {
|
|
64
|
+
await super.connect();
|
|
65
|
+
// 连接成功后启动消费者和恢复机制
|
|
66
|
+
this.startConsumer();
|
|
67
|
+
this.startRecovery();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 断开 Redis 连接并停止消费者
|
|
71
|
+
* 会等待所有正在处理的批次完成后再断开连接
|
|
72
|
+
*/
|
|
73
|
+
async disconnect() {
|
|
74
|
+
// 先停止消费者和恢复机制,不再接受新任务
|
|
75
|
+
this.stopConsumer();
|
|
76
|
+
this.stopRecovery();
|
|
77
|
+
// 等待所有正在处理的批次完成
|
|
78
|
+
if (this.processingPromises.size > 0) {
|
|
79
|
+
console.log(`[RedisQueueBatchConsumer] Waiting for ${this.processingPromises.size} batches to complete...`);
|
|
80
|
+
await Promise.allSettled(this.processingPromises);
|
|
81
|
+
console.log(`[RedisQueueBatchConsumer] All batches completed`);
|
|
82
|
+
}
|
|
83
|
+
// 最后断开 Redis 连接
|
|
84
|
+
super.disconnect();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 批量处理任务
|
|
88
|
+
*/
|
|
89
|
+
async processBatch(taskIds) {
|
|
90
|
+
this.processingBatches++;
|
|
91
|
+
// 创建一个 Promise 来跟踪这个批次的处理过程
|
|
92
|
+
const batchPromise = (async () => {
|
|
93
|
+
try {
|
|
94
|
+
// 获取所有任务
|
|
95
|
+
const tasks = await Promise.all(taskIds.map(id => this.getTask(id)));
|
|
96
|
+
const validTasks = tasks.filter((task) => task !== null);
|
|
97
|
+
if (validTasks.length === 0) {
|
|
98
|
+
console.warn(`[RedisQueueBatchConsumer] No valid tasks found in batch`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// 过滤出有效的待处理任务
|
|
102
|
+
const tasksToProcess = [];
|
|
103
|
+
for (const task of validTasks) {
|
|
104
|
+
// 检查状态
|
|
105
|
+
if (task.status !== 'pending') {
|
|
106
|
+
console.log(`[RedisQueueBatchConsumer] Task ${task.id} has invalid status (${task.status}), marking as failed`);
|
|
107
|
+
await this.applyStatus(task.id, task.status, 'failed');
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
tasksToProcess.push(task);
|
|
111
|
+
}
|
|
112
|
+
if (tasksToProcess.length === 0) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
// 任务已在 processing 队列中(由 Lua 脚本完成),只需更新状态和开始时间
|
|
117
|
+
const taskIdList = tasksToProcess.map(t => t.id);
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
await Promise.all(tasksToProcess.map(task => {
|
|
120
|
+
task.status = 'processing';
|
|
121
|
+
task.processingStartTime = now;
|
|
122
|
+
const taskKey = `${this.queueKey}:task:${task.id}`;
|
|
123
|
+
return this.redis.setEx(taskKey, this.cleanupDelay, JSON.stringify(task));
|
|
124
|
+
}));
|
|
125
|
+
// 批量处理所有任务
|
|
126
|
+
const dataList = tasksToProcess.map(t => t.data);
|
|
127
|
+
await this.config.handler(dataList);
|
|
128
|
+
// 批量更新状态为完成
|
|
129
|
+
const updatedCount = await this.applyStatusBatch(taskIdList, 'processing', 'completed');
|
|
130
|
+
console.log(`[RedisQueueBatchConsumer] Batch completed: ${updatedCount} tasks`);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
console.error(`[RedisQueueBatchConsumer] Batch failed:`, error);
|
|
134
|
+
// 批量重试或失败
|
|
135
|
+
for (const task of tasksToProcess) {
|
|
136
|
+
if (task.retryCount < task.maxRetries) {
|
|
137
|
+
task.retryCount++;
|
|
138
|
+
task.status = 'pending';
|
|
139
|
+
task.processingStartTime = undefined;
|
|
140
|
+
const taskKey = `${this.queueKey}:task:${task.id}`;
|
|
141
|
+
const script = `
|
|
142
|
+
redis.call('SETEX', KEYS[1], ARGV[1], ARGV[2])
|
|
143
|
+
redis.call('LREM', KEYS[2], 0, ARGV[3])
|
|
144
|
+
redis.call('RPUSH', KEYS[3], ARGV[3])
|
|
145
|
+
return 1
|
|
146
|
+
`;
|
|
147
|
+
await this.redis.eval(script, {
|
|
148
|
+
keys: [taskKey, this.processingQueue, this.pendingQueue],
|
|
149
|
+
arguments: [this.cleanupDelay.toString(), JSON.stringify(task), task.id],
|
|
150
|
+
});
|
|
151
|
+
console.log(`[RedisQueueBatchConsumer] Task ${task.id} will retry (${task.retryCount}/${task.maxRetries})`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
task.status = 'failed';
|
|
155
|
+
task.processingStartTime = undefined;
|
|
156
|
+
const taskKey = `${this.queueKey}:task:${task.id}`;
|
|
157
|
+
const script = `
|
|
158
|
+
redis.call('SETEX', KEYS[1], ARGV[1], ARGV[2])
|
|
159
|
+
redis.call('LREM', KEYS[2], 0, ARGV[3])
|
|
160
|
+
redis.call('RPUSH', KEYS[3], ARGV[3])
|
|
161
|
+
return 1
|
|
162
|
+
`;
|
|
163
|
+
await this.redis.eval(script, {
|
|
164
|
+
keys: [taskKey, this.processingQueue, this.failedQueue],
|
|
165
|
+
arguments: [this.cleanupDelay.toString(), JSON.stringify(task), task.id],
|
|
166
|
+
});
|
|
167
|
+
console.error(`[RedisQueueBatchConsumer] Task ${task.id} failed after ${task.maxRetries} retries`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
this.processingBatches--;
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
// 将批次 Promise 添加到集合中
|
|
177
|
+
this.processingPromises.add(batchPromise);
|
|
178
|
+
// 批次完成后从集合中移除
|
|
179
|
+
batchPromise.finally(() => {
|
|
180
|
+
this.processingPromises.delete(batchPromise);
|
|
181
|
+
});
|
|
182
|
+
// 返回批次 Promise
|
|
183
|
+
return batchPromise;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 启动恢复机制
|
|
187
|
+
*/
|
|
188
|
+
startRecovery() {
|
|
189
|
+
if (this.recoveryInterval || !this.redis) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// 立即执行一次恢复
|
|
193
|
+
this.recoverStalled(this.config.processingTimeout).catch(error => {
|
|
194
|
+
console.error('[RedisQueueBatchConsumer] Initial recovery error:', error);
|
|
195
|
+
});
|
|
196
|
+
// 定期检查(每 10 秒检查一次)
|
|
197
|
+
this.recoveryInterval = setInterval(() => {
|
|
198
|
+
this.recoverStalled(this.config.processingTimeout).catch(error => {
|
|
199
|
+
console.error('[RedisQueueBatchConsumer] Recovery error:', error);
|
|
200
|
+
});
|
|
201
|
+
}, 10000);
|
|
202
|
+
console.log('[RedisQueueBatchConsumer] Recovery mechanism started');
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* 停止恢复机制
|
|
206
|
+
*/
|
|
207
|
+
stopRecovery() {
|
|
208
|
+
if (this.recoveryInterval) {
|
|
209
|
+
clearInterval(this.recoveryInterval);
|
|
210
|
+
this.recoveryInterval = null;
|
|
211
|
+
console.log('[RedisQueueBatchConsumer] Recovery mechanism stopped');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* 启动消费者
|
|
216
|
+
*/
|
|
217
|
+
startConsumer() {
|
|
218
|
+
if (this.consumerRunning || !this.redis) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.consumerRunning = true;
|
|
222
|
+
console.log(`[RedisQueueBatchConsumer] Consumer started with batchSize: ${this.config.batchSize}, concurrency: ${this.config.concurrency}`);
|
|
223
|
+
// Lua 脚本: 批量原子化从 pending 取出任务并移到 processing
|
|
224
|
+
const popAndMoveBatchScript = `
|
|
225
|
+
local pendingQueue = KEYS[1]
|
|
226
|
+
local processingQueue = KEYS[2]
|
|
227
|
+
local queueKeyPrefix = KEYS[3]
|
|
228
|
+
local count = tonumber(ARGV[1])
|
|
229
|
+
local currentTime = tonumber(ARGV[2])
|
|
230
|
+
|
|
231
|
+
local taskIds = {}
|
|
232
|
+
local checkedCount = 0
|
|
233
|
+
local maxCheck = count * 3
|
|
234
|
+
|
|
235
|
+
while #taskIds < count and checkedCount < maxCheck do
|
|
236
|
+
local taskId = redis.call('LPOP', pendingQueue)
|
|
237
|
+
if not taskId then
|
|
238
|
+
break
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
checkedCount = checkedCount + 1
|
|
242
|
+
|
|
243
|
+
local taskKey = queueKeyPrefix .. ':task:' .. taskId
|
|
244
|
+
local taskData = redis.call('GET', taskKey)
|
|
245
|
+
|
|
246
|
+
if taskData then
|
|
247
|
+
local task = cjson.decode(taskData)
|
|
248
|
+
local delayUntil = task.delayUntil
|
|
249
|
+
|
|
250
|
+
if not delayUntil or delayUntil <= currentTime then
|
|
251
|
+
redis.call('RPUSH', processingQueue, taskId)
|
|
252
|
+
table.insert(taskIds, taskId)
|
|
253
|
+
else
|
|
254
|
+
redis.call('RPUSH', pendingQueue, taskId)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
return taskIds
|
|
260
|
+
`;
|
|
261
|
+
this.consumerInterval = setInterval(async () => {
|
|
262
|
+
try {
|
|
263
|
+
// 检查当前是否有可用的并发槽位
|
|
264
|
+
const availableSlots = this.config.concurrency - this.processingBatches;
|
|
265
|
+
if (availableSlots <= 0) {
|
|
266
|
+
return; // 已达到并发限制
|
|
267
|
+
}
|
|
268
|
+
// 批量取出任务
|
|
269
|
+
const taskIds = await this.redis.eval(popAndMoveBatchScript, {
|
|
270
|
+
keys: [this.pendingQueue, this.processingQueue, this.queueKey],
|
|
271
|
+
arguments: [this.config.batchSize.toString(), Date.now().toString()],
|
|
272
|
+
});
|
|
273
|
+
if (taskIds.length > 0) {
|
|
274
|
+
// 处理这批任务(不等待完成)
|
|
275
|
+
this.processBatch(taskIds).catch(error => {
|
|
276
|
+
console.error(`[RedisQueueBatchConsumer] Unhandled error in processBatch:`, error);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
console.error('[RedisQueueBatchConsumer] Consumer error:', error);
|
|
282
|
+
}
|
|
283
|
+
}, this.config.consumerInterval);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* 停止消费者
|
|
287
|
+
*/
|
|
288
|
+
stopConsumer() {
|
|
289
|
+
if (this.consumerInterval) {
|
|
290
|
+
clearInterval(this.consumerInterval);
|
|
291
|
+
this.consumerInterval = null;
|
|
292
|
+
}
|
|
293
|
+
this.consumerRunning = false;
|
|
294
|
+
console.log('[RedisQueueBatchConsumer] Consumer stopped');
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* 获取队列统计信息(O(1) 时间复杂度)
|
|
298
|
+
*/
|
|
299
|
+
async statistics() {
|
|
300
|
+
if (!this.redis) {
|
|
301
|
+
return { pending: 0, processing: 0, completed: 0, failed: 0 };
|
|
302
|
+
}
|
|
303
|
+
const [pending, processing, completed, failed] = await Promise.all([
|
|
304
|
+
this.redis.lLen(this.pendingQueue),
|
|
305
|
+
this.redis.lLen(this.processingQueue),
|
|
306
|
+
this.redis.lLen(this.completedQueue),
|
|
307
|
+
this.redis.lLen(this.failedQueue),
|
|
308
|
+
]);
|
|
309
|
+
return { pending, processing, completed, failed };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 健康检查
|
|
313
|
+
*/
|
|
314
|
+
async health() {
|
|
315
|
+
if (!this.redis)
|
|
316
|
+
return false;
|
|
317
|
+
const result = await catchIt(() => this.redis.ping());
|
|
318
|
+
return !result.isError() && result.value === 'PONG';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type TestTaskData = {
|
|
2
|
+
id?: string;
|
|
3
|
+
value?: number | string;
|
|
4
|
+
test?: boolean | string;
|
|
5
|
+
index?: number;
|
|
6
|
+
payload?: string;
|
|
7
|
+
order?: number;
|
|
8
|
+
taskId?: number;
|
|
9
|
+
message?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
shouldFail?: boolean;
|
|
12
|
+
step?: number;
|
|
13
|
+
real?: boolean;
|
|
14
|
+
nested?: {
|
|
15
|
+
value: number;
|
|
16
|
+
};
|
|
17
|
+
example?: string;
|
|
18
|
+
data?: any;
|
|
19
|
+
};
|
|
20
|
+
declare module './types' {
|
|
21
|
+
interface RedisQueueRegistry {
|
|
22
|
+
[key: `test:batch:${string}`]: TestTaskData;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=redis-queue-batch-consumer.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis-queue-batch-consumer.test.d.ts","sourceRoot":"","sources":["../../source/redis-queue/redis-queue-batch-consumer.test.ts"],"names":[],"mappings":"AAQA,KAAK,YAAY,GAAG;IAClB,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACvB,IAAI,CAAC,EAAE,OAAO,GAAG,MAAM,CAAA;IACvB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,GAAG,CAAA;CACX,CAAA;AAGD,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,kBAAkB;QAC1B,CAAC,GAAG,EAAE,cAAc,MAAM,EAAE,GAAG,YAAY,CAAA;KAC5C;CACF"}
|