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