@taicode/common-server 3.1.0 → 3.2.0
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/context/index.d.ts +6 -0
- package/output/context/index.d.ts.map +1 -0
- package/output/context/index.js +14 -0
- package/output/context/index.test.d.ts +2 -0
- package/output/context/index.test.d.ts.map +1 -0
- package/output/context/index.test.js +14 -0
- package/output/index.d.ts +2 -0
- package/output/index.d.ts.map +1 -1
- package/output/index.js +2 -0
- package/output/rate-limiter/index.d.ts +4 -0
- package/output/rate-limiter/index.d.ts.map +1 -0
- package/output/rate-limiter/index.js +3 -0
- package/output/rate-limiter/memory-store.d.ts +12 -0
- package/output/rate-limiter/memory-store.d.ts.map +1 -0
- package/output/rate-limiter/memory-store.js +46 -0
- package/output/rate-limiter/rate-limiter.d.ts +17 -0
- package/output/rate-limiter/rate-limiter.d.ts.map +1 -0
- package/output/rate-limiter/rate-limiter.js +64 -0
- package/output/rate-limiter/types.d.ts +27 -0
- package/output/rate-limiter/types.d.ts.map +1 -0
- package/output/rate-limiter/types.js +1 -0
- package/package.json +8 -8
- package/output/redis-queue/redis-batch-consumer.d.ts +0 -85
- package/output/redis-queue/redis-batch-consumer.d.ts.map +0 -1
- package/output/redis-queue/redis-batch-consumer.js +0 -360
- package/output/redis-queue/redis-batch-consumer.test.d.ts +0 -7
- package/output/redis-queue/redis-batch-consumer.test.d.ts.map +0 -1
- package/output/redis-queue/redis-batch-consumer.test.js +0 -265
- package/output/redis-queue/registry.d.ts +0 -58
- package/output/redis-queue/registry.d.ts.map +0 -1
- package/output/redis-queue/registry.js +0 -31
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../source/context/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAEhE,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,iBAAiB,CAAA;CACvB;AAcD,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,GAAG,cAAc,CAErE"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const fallbackLogger = {
|
|
2
|
+
trace: () => { },
|
|
3
|
+
debug: () => { },
|
|
4
|
+
info: () => { },
|
|
5
|
+
warn: () => { },
|
|
6
|
+
error: () => { },
|
|
7
|
+
fatal: () => { },
|
|
8
|
+
child: () => fallbackLogger,
|
|
9
|
+
level: 'info',
|
|
10
|
+
silent: () => { },
|
|
11
|
+
};
|
|
12
|
+
export function createContext(request) {
|
|
13
|
+
return { log: request.log || fallbackLogger };
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../../source/context/index.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createContext } from './index.js';
|
|
3
|
+
describe('createContext', () => {
|
|
4
|
+
it('uses the request logger when one is available', () => {
|
|
5
|
+
const log = { info: () => { } };
|
|
6
|
+
const context = createContext({ log });
|
|
7
|
+
expect(context.log).toBe(log);
|
|
8
|
+
});
|
|
9
|
+
it('provides a fallback logger for lightweight test requests', () => {
|
|
10
|
+
const context = createContext({ log: undefined });
|
|
11
|
+
expect(context.log.level).toBe('info');
|
|
12
|
+
expect(() => context.log.info('message')).not.toThrow();
|
|
13
|
+
});
|
|
14
|
+
});
|
package/output/index.d.ts
CHANGED
package/output/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AACA,cAAc,mBAAmB,CAAA;AACjC,cAAc,wBAAwB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../source/index.ts"],"names":[],"mappings":"AACA,cAAc,mBAAmB,CAAA;AACjC,cAAc,wBAAwB,CAAA;AACtC,cAAc,yBAAyB,CAAA;AACvC,cAAc,oBAAoB,CAAA"}
|
package/output/index.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../source/rate-limiter/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,mBAAmB,CAAA;AACjC,cAAc,mBAAmB,CAAA"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RateLimitInfo, RateLimitStore } from './types.js';
|
|
2
|
+
export declare class MemoryRateLimitStore implements RateLimitStore {
|
|
3
|
+
private store;
|
|
4
|
+
private cleanupInterval;
|
|
5
|
+
constructor(cleanupIntervalMs?: number);
|
|
6
|
+
get(key: string): Promise<RateLimitInfo | null>;
|
|
7
|
+
set(key: string, info: RateLimitInfo): Promise<void>;
|
|
8
|
+
delete(key: string): Promise<void>;
|
|
9
|
+
cleanup(): Promise<void>;
|
|
10
|
+
destroy(): void;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=memory-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory-store.d.ts","sourceRoot":"","sources":["../../source/rate-limiter/memory-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAO/D,qBAAa,oBAAqB,YAAW,cAAc;IACzD,OAAO,CAAC,KAAK,CAAsC;IACnD,OAAO,CAAC,eAAe,CAA8B;gBAEzC,iBAAiB,SAAQ;IAY/B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAY/C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAKpD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAS9B,OAAO,IAAI,IAAI;CAOhB"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export class MemoryRateLimitStore {
|
|
2
|
+
store = new Map();
|
|
3
|
+
cleanupInterval = null;
|
|
4
|
+
constructor(cleanupIntervalMs = 60000) {
|
|
5
|
+
// 定期清理过期数据
|
|
6
|
+
this.cleanupInterval = setInterval(() => {
|
|
7
|
+
this.cleanup();
|
|
8
|
+
}, cleanupIntervalMs);
|
|
9
|
+
// 防止内存泄漏
|
|
10
|
+
if (this.cleanupInterval.unref) {
|
|
11
|
+
this.cleanupInterval.unref();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async get(key) {
|
|
15
|
+
const entry = this.store.get(key);
|
|
16
|
+
if (!entry)
|
|
17
|
+
return null;
|
|
18
|
+
if (Date.now() > entry.expiresAt) {
|
|
19
|
+
this.store.delete(key);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return entry.info;
|
|
23
|
+
}
|
|
24
|
+
async set(key, info) {
|
|
25
|
+
const expiresAt = info.windowEnd.getTime();
|
|
26
|
+
this.store.set(key, { info, expiresAt });
|
|
27
|
+
}
|
|
28
|
+
async delete(key) {
|
|
29
|
+
this.store.delete(key);
|
|
30
|
+
}
|
|
31
|
+
async cleanup() {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
for (const [key, entry] of this.store.entries()) {
|
|
34
|
+
if (now > entry.expiresAt) {
|
|
35
|
+
this.store.delete(key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
destroy() {
|
|
40
|
+
if (this.cleanupInterval) {
|
|
41
|
+
clearInterval(this.cleanupInterval);
|
|
42
|
+
this.cleanupInterval = null;
|
|
43
|
+
}
|
|
44
|
+
this.store.clear();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RateLimitConfig, RateLimitInfo, RateLimiterOptions, RateLimitResult } from './types.js';
|
|
2
|
+
export declare class RateLimiter {
|
|
3
|
+
private store;
|
|
4
|
+
constructor(options?: RateLimiterOptions);
|
|
5
|
+
checkLimit(key: string, config: RateLimitConfig): Promise<RateLimitResult>;
|
|
6
|
+
resetLimit(key: string): Promise<void>;
|
|
7
|
+
getLimitInfo(key: string): Promise<RateLimitInfo | null>;
|
|
8
|
+
/**
|
|
9
|
+
* 检查多个限流键,取最严格的限制
|
|
10
|
+
*/
|
|
11
|
+
checkMultiLimit(keys: string[], config: RateLimitConfig): Promise<RateLimitResult>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 创建限流键的辅助函数
|
|
15
|
+
*/
|
|
16
|
+
export declare function createRateLimitKey(prefix: string, ...parts: string[]): string;
|
|
17
|
+
//# sourceMappingURL=rate-limiter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../../source/rate-limiter/rate-limiter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,kBAAkB,EAAE,eAAe,EAAkB,MAAM,YAAY,CAAA;AAGrH,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAgB;gBAEjB,OAAO,GAAE,kBAAuB;IAItC,UAAU,CACd,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,eAAe,GACtB,OAAO,CAAC,eAAe,CAAC;IAoCrB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAItC,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAI9D;;OAEG;IACG,eAAe,CACnB,IAAI,EAAE,MAAM,EAAE,EACd,MAAM,EAAE,eAAe,GACtB,OAAO,CAAC,eAAe,CAAC;CAe5B;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAE7E"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { MemoryRateLimitStore } from './memory-store.js';
|
|
2
|
+
export class RateLimiter {
|
|
3
|
+
store;
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.store = options.store || new MemoryRateLimitStore();
|
|
6
|
+
}
|
|
7
|
+
async checkLimit(key, config) {
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const windowStart = new Date(now.getTime());
|
|
10
|
+
const windowEnd = new Date(now.getTime() + config.window * 1000);
|
|
11
|
+
// 获取当前计数
|
|
12
|
+
let currentInfo = await this.store.get(key);
|
|
13
|
+
// 如果没有记录,或者窗口已过期,创建新记录
|
|
14
|
+
if (!currentInfo || currentInfo.windowEnd < now) {
|
|
15
|
+
currentInfo = {
|
|
16
|
+
count: 0,
|
|
17
|
+
windowStart,
|
|
18
|
+
windowEnd,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// 增加计数
|
|
22
|
+
currentInfo.count += 1;
|
|
23
|
+
// 保存到存储
|
|
24
|
+
await this.store.set(key, currentInfo);
|
|
25
|
+
// 计算是否允许
|
|
26
|
+
const allowed = currentInfo.count <= config.limit;
|
|
27
|
+
const remaining = Math.max(0, config.limit - currentInfo.count);
|
|
28
|
+
return {
|
|
29
|
+
allowed,
|
|
30
|
+
remaining,
|
|
31
|
+
resetAt: currentInfo.windowEnd,
|
|
32
|
+
limit: config.limit,
|
|
33
|
+
window: config.window,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async resetLimit(key) {
|
|
37
|
+
await this.store.delete(key);
|
|
38
|
+
}
|
|
39
|
+
async getLimitInfo(key) {
|
|
40
|
+
return await this.store.get(key);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 检查多个限流键,取最严格的限制
|
|
44
|
+
*/
|
|
45
|
+
async checkMultiLimit(keys, config) {
|
|
46
|
+
let strictestResult = null;
|
|
47
|
+
for (const key of keys) {
|
|
48
|
+
const result = await this.checkLimit(key, config);
|
|
49
|
+
if (!strictestResult || !result.allowed) {
|
|
50
|
+
strictestResult = result;
|
|
51
|
+
}
|
|
52
|
+
else if (result.remaining < strictestResult.remaining) {
|
|
53
|
+
strictestResult = result;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return strictestResult;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 创建限流键的辅助函数
|
|
61
|
+
*/
|
|
62
|
+
export function createRateLimitKey(prefix, ...parts) {
|
|
63
|
+
return `rate-limit:${prefix}:${parts.join(':')}`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface RateLimitInfo {
|
|
2
|
+
count: number;
|
|
3
|
+
windowStart: Date;
|
|
4
|
+
windowEnd: Date;
|
|
5
|
+
}
|
|
6
|
+
export interface RateLimitResult {
|
|
7
|
+
allowed: boolean;
|
|
8
|
+
remaining: number;
|
|
9
|
+
resetAt: Date;
|
|
10
|
+
limit: number;
|
|
11
|
+
window: number;
|
|
12
|
+
}
|
|
13
|
+
export interface RateLimitStore {
|
|
14
|
+
get(key: string): Promise<RateLimitInfo | null>;
|
|
15
|
+
set(key: string, info: RateLimitInfo): Promise<void>;
|
|
16
|
+
delete(key: string): Promise<void>;
|
|
17
|
+
cleanup?(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export interface RateLimitConfig {
|
|
20
|
+
limit: number;
|
|
21
|
+
window: number;
|
|
22
|
+
lockout?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface RateLimiterOptions {
|
|
25
|
+
store?: RateLimitStore;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../source/rate-limiter/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,IAAI,CAAA;IACjB,SAAS,EAAE,IAAI,CAAA;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,IAAI,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAA;IAC/C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACpD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,OAAO,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,cAAc,CAAA;CACvB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taicode/common-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"author": "Alain",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"description": "",
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "vitest run",
|
|
25
|
-
"build": "tsc -p tsconfig.json",
|
|
25
|
+
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
|
26
26
|
"prepublishOnly": "npm run build",
|
|
27
|
-
"dev": "tsc -p tsconfig.json --watch"
|
|
27
|
+
"dev": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --watch"
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"output"
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@taicode/common-base": "^3.1.0",
|
|
34
34
|
"fastify": "^5.5.0",
|
|
35
|
-
"redis": "^5.
|
|
35
|
+
"redis": "^5.11.0",
|
|
36
36
|
"zod": "^4.1.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@types/node": "^22.
|
|
40
|
-
"fastify": "^5.
|
|
41
|
-
"redis": "^5.
|
|
39
|
+
"@types/node": "^22.19.13",
|
|
40
|
+
"fastify": "^5.7.4",
|
|
41
|
+
"redis": "^5.11.0",
|
|
42
42
|
"vitest": "^3.2.4",
|
|
43
|
-
"zod": "^4.
|
|
43
|
+
"zod": "^4.3.6"
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { RedisQueueCommon } from './redis-queue-common';
|
|
2
|
-
import type { BatchConsumerConfig, QueueStats, InferTaskData } from './types';
|
|
3
|
-
/**
|
|
4
|
-
* Redis 批量队列消费者
|
|
5
|
-
*
|
|
6
|
-
* 批量消费任务,每次处理多条
|
|
7
|
-
*
|
|
8
|
-
* @template K 队列键名类型
|
|
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 declare class RedisBatchConsumer<K extends string = string> extends RedisQueueCommon<InferTaskData<K>> {
|
|
36
|
-
private consumerRunning;
|
|
37
|
-
private consumerInterval;
|
|
38
|
-
private recoveryInterval;
|
|
39
|
-
private processingBatches;
|
|
40
|
-
private readonly config;
|
|
41
|
-
constructor(config: BatchConsumerConfig<K>);
|
|
42
|
-
protected getLogPrefix(): string;
|
|
43
|
-
/**
|
|
44
|
-
* 连接 Redis 并自动启动消费者
|
|
45
|
-
*/
|
|
46
|
-
connect(): Promise<void>;
|
|
47
|
-
/**
|
|
48
|
-
* 断开 Redis 连接并停止消费者
|
|
49
|
-
*/
|
|
50
|
-
disconnect(): void;
|
|
51
|
-
/**
|
|
52
|
-
* 恢复超时的任务
|
|
53
|
-
* 检查 processing 队列中的任务,将超时的任务直接标记为失败
|
|
54
|
-
*/
|
|
55
|
-
private recoverStalledTasks;
|
|
56
|
-
/**
|
|
57
|
-
* 批量处理任务
|
|
58
|
-
*/
|
|
59
|
-
private processBatch;
|
|
60
|
-
/**
|
|
61
|
-
* 启动恢复机制
|
|
62
|
-
*/
|
|
63
|
-
private startRecovery;
|
|
64
|
-
/**
|
|
65
|
-
* 停止恢复机制
|
|
66
|
-
*/
|
|
67
|
-
private stopRecovery;
|
|
68
|
-
/**
|
|
69
|
-
* 启动消费者
|
|
70
|
-
*/
|
|
71
|
-
private startConsumer;
|
|
72
|
-
/**
|
|
73
|
-
* 停止消费者
|
|
74
|
-
*/
|
|
75
|
-
private stopConsumer;
|
|
76
|
-
/**
|
|
77
|
-
* 获取队列统计信息(O(1) 时间复杂度)
|
|
78
|
-
*/
|
|
79
|
-
statistics(): Promise<QueueStats>;
|
|
80
|
-
/**
|
|
81
|
-
* 健康检查
|
|
82
|
-
*/
|
|
83
|
-
health(): Promise<boolean>;
|
|
84
|
-
}
|
|
85
|
-
//# sourceMappingURL=redis-batch-consumer.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
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,EAA4C,UAAU,EAAE,aAAa,EAAY,MAAM,SAAS,CAAA;AAEjI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,qBAAa,kBAAkB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,gBAAgB,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;IACnG,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;;;OAGG;YACW,mBAAmB;IAwDjC;;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;CAMjC"}
|
|
@@ -1,360 +0,0 @@
|
|
|
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
|
-
* 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 extends RedisQueueCommon {
|
|
36
|
-
consumerRunning = false;
|
|
37
|
-
consumerInterval = null;
|
|
38
|
-
recoveryInterval = null;
|
|
39
|
-
processingBatches = 0; // 当前正在处理的批次数
|
|
40
|
-
config;
|
|
41
|
-
constructor(config) {
|
|
42
|
-
// 验证必填参数
|
|
43
|
-
if (!config.handler) {
|
|
44
|
-
throw new Error('[RedisBatchConsumer] handler is required');
|
|
45
|
-
}
|
|
46
|
-
// 调用父类构造函数
|
|
47
|
-
super({
|
|
48
|
-
redisUrl: config.redisUrl,
|
|
49
|
-
redisClient: config.redisClient,
|
|
50
|
-
queueKey: config.queueKey,
|
|
51
|
-
cleanupDelay: config.cleanupDelay ?? 86400,
|
|
52
|
-
});
|
|
53
|
-
this.config = {
|
|
54
|
-
handler: config.handler,
|
|
55
|
-
queueKey: config.queueKey,
|
|
56
|
-
redisUrl: config.redisUrl,
|
|
57
|
-
redisClient: config.redisClient,
|
|
58
|
-
batchSize: config.batchSize ?? 10,
|
|
59
|
-
maxRetries: config.maxRetries ?? 3,
|
|
60
|
-
concurrency: config.concurrency ?? 1,
|
|
61
|
-
consumerInterval: config.consumerInterval ?? 1000,
|
|
62
|
-
processingTimeout: config.processingTimeout ?? 60000, // 60 seconds
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
getLogPrefix() {
|
|
66
|
-
return 'RedisBatchConsumer';
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* 连接 Redis 并自动启动消费者
|
|
70
|
-
*/
|
|
71
|
-
async connect() {
|
|
72
|
-
await super.connect();
|
|
73
|
-
// 连接成功后启动消费者和恢复机制
|
|
74
|
-
this.startConsumer();
|
|
75
|
-
this.startRecovery();
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* 断开 Redis 连接并停止消费者
|
|
79
|
-
*/
|
|
80
|
-
disconnect() {
|
|
81
|
-
this.stopConsumer();
|
|
82
|
-
this.stopRecovery();
|
|
83
|
-
super.disconnect();
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* 恢复超时的任务
|
|
87
|
-
* 检查 processing 队列中的任务,将超时的任务直接标记为失败
|
|
88
|
-
*/
|
|
89
|
-
async recoverStalledTasks() {
|
|
90
|
-
if (!this.redis)
|
|
91
|
-
return;
|
|
92
|
-
try {
|
|
93
|
-
const processingTaskIds = await this.redis.lRange(this.processingQueue, 0, -1);
|
|
94
|
-
if (processingTaskIds.length === 0) {
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
const now = Date.now();
|
|
98
|
-
let recoveredCount = 0;
|
|
99
|
-
for (const taskId of processingTaskIds) {
|
|
100
|
-
const task = await this.getTask(taskId);
|
|
101
|
-
if (!task) {
|
|
102
|
-
// 任务不存在,从队列中移除
|
|
103
|
-
await this.redis.lRem(this.processingQueue, 0, taskId);
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
// 检查是否超时
|
|
107
|
-
const processingTime = now - (task.processingStartTime || now);
|
|
108
|
-
if (processingTime > this.config.processingTimeout) {
|
|
109
|
-
console.log(`[RedisBatchConsumer] Task timeout: ${taskId} (processing time: ${processingTime}ms)`);
|
|
110
|
-
const taskKey = `${this.queueKey}:task:${taskId}`;
|
|
111
|
-
// 超时直接标记为失败
|
|
112
|
-
task.status = 'failed';
|
|
113
|
-
task.processingStartTime = undefined;
|
|
114
|
-
// 使用 Lua 脚本确保原子性
|
|
115
|
-
const script = `
|
|
116
|
-
redis.call('SETEX', KEYS[1], ARGV[1], ARGV[2])
|
|
117
|
-
redis.call('LREM', KEYS[2], 0, ARGV[3])
|
|
118
|
-
redis.call('RPUSH', KEYS[3], ARGV[3])
|
|
119
|
-
return 1
|
|
120
|
-
`;
|
|
121
|
-
await this.redis.eval(script, {
|
|
122
|
-
keys: [taskKey, this.processingQueue, this.failedQueue],
|
|
123
|
-
arguments: [this.cleanupDelay.toString(), JSON.stringify(task), taskId],
|
|
124
|
-
});
|
|
125
|
-
console.error(`[RedisBatchConsumer] Task ${taskId} failed after timeout`);
|
|
126
|
-
recoveredCount++;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
if (recoveredCount > 0) {
|
|
130
|
-
console.log(`[RedisBatchConsumer] Recovered ${recoveredCount} timeout tasks`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
console.error('[RedisBatchConsumer] Failed to recover stalled tasks:', error);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* 批量处理任务
|
|
139
|
-
*/
|
|
140
|
-
async processBatch(taskIds) {
|
|
141
|
-
this.processingBatches++;
|
|
142
|
-
try {
|
|
143
|
-
// 获取所有任务
|
|
144
|
-
const tasks = await Promise.all(taskIds.map(id => this.getTask(id)));
|
|
145
|
-
const validTasks = tasks.filter((task) => task !== null);
|
|
146
|
-
if (validTasks.length === 0) {
|
|
147
|
-
console.warn(`[RedisBatchConsumer] No valid tasks found in batch`);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
// 过滤出有效的待处理任务
|
|
151
|
-
const tasksToProcess = [];
|
|
152
|
-
for (const task of validTasks) {
|
|
153
|
-
// 检查状态
|
|
154
|
-
if (task.status !== 'pending') {
|
|
155
|
-
console.log(`[RedisBatchConsumer] Task ${task.id} has invalid status (${task.status}), marking as failed`);
|
|
156
|
-
await this.applyStatus(task.id, task.status, 'failed');
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
tasksToProcess.push(task);
|
|
160
|
-
}
|
|
161
|
-
if (tasksToProcess.length === 0) {
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
try {
|
|
165
|
-
// 任务已在 processing 队列中(由 Lua 脚本完成),只需更新状态和开始时间
|
|
166
|
-
const taskIdList = tasksToProcess.map(t => t.id);
|
|
167
|
-
const now = Date.now();
|
|
168
|
-
await Promise.all(tasksToProcess.map(task => {
|
|
169
|
-
task.status = 'processing';
|
|
170
|
-
task.processingStartTime = now;
|
|
171
|
-
const taskKey = `${this.queueKey}:task:${task.id}`;
|
|
172
|
-
return this.redis.setEx(taskKey, this.cleanupDelay, JSON.stringify(task));
|
|
173
|
-
}));
|
|
174
|
-
// 批量处理所有任务
|
|
175
|
-
const dataList = tasksToProcess.map(t => t.data);
|
|
176
|
-
await this.config.handler(dataList);
|
|
177
|
-
// 批量更新状态为完成
|
|
178
|
-
const updatedCount = await this.applyStatusBatch(taskIdList, 'processing', 'completed');
|
|
179
|
-
console.log(`[RedisBatchConsumer] Batch completed: ${updatedCount} tasks`);
|
|
180
|
-
}
|
|
181
|
-
catch (error) {
|
|
182
|
-
console.error(`[RedisBatchConsumer] Batch failed:`, error);
|
|
183
|
-
// 批量重试或失败
|
|
184
|
-
for (const task of tasksToProcess) {
|
|
185
|
-
if (task.retryCount < task.maxRetries) {
|
|
186
|
-
task.retryCount++;
|
|
187
|
-
task.status = 'pending';
|
|
188
|
-
task.processingStartTime = undefined;
|
|
189
|
-
const taskKey = `${this.queueKey}:task:${task.id}`;
|
|
190
|
-
const script = `
|
|
191
|
-
redis.call('SETEX', KEYS[1], ARGV[1], ARGV[2])
|
|
192
|
-
redis.call('LREM', KEYS[2], 0, ARGV[3])
|
|
193
|
-
redis.call('RPUSH', KEYS[3], ARGV[3])
|
|
194
|
-
return 1
|
|
195
|
-
`;
|
|
196
|
-
await this.redis.eval(script, {
|
|
197
|
-
keys: [taskKey, this.processingQueue, this.pendingQueue],
|
|
198
|
-
arguments: [this.cleanupDelay.toString(), JSON.stringify(task), task.id],
|
|
199
|
-
});
|
|
200
|
-
console.log(`[RedisBatchConsumer] Task ${task.id} will retry (${task.retryCount}/${task.maxRetries})`);
|
|
201
|
-
}
|
|
202
|
-
else {
|
|
203
|
-
task.status = 'failed';
|
|
204
|
-
task.processingStartTime = undefined;
|
|
205
|
-
const taskKey = `${this.queueKey}:task:${task.id}`;
|
|
206
|
-
const script = `
|
|
207
|
-
redis.call('SETEX', KEYS[1], ARGV[1], ARGV[2])
|
|
208
|
-
redis.call('LREM', KEYS[2], 0, ARGV[3])
|
|
209
|
-
redis.call('RPUSH', KEYS[3], ARGV[3])
|
|
210
|
-
return 1
|
|
211
|
-
`;
|
|
212
|
-
await this.redis.eval(script, {
|
|
213
|
-
keys: [taskKey, this.processingQueue, this.failedQueue],
|
|
214
|
-
arguments: [this.cleanupDelay.toString(), JSON.stringify(task), task.id],
|
|
215
|
-
});
|
|
216
|
-
console.error(`[RedisBatchConsumer] Task ${task.id} failed after ${task.maxRetries} retries`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
finally {
|
|
222
|
-
this.processingBatches--;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* 启动恢复机制
|
|
227
|
-
*/
|
|
228
|
-
startRecovery() {
|
|
229
|
-
if (this.recoveryInterval || !this.redis) {
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
// 立即执行一次恢复
|
|
233
|
-
this.recoverStalledTasks().catch(error => {
|
|
234
|
-
console.error('[RedisBatchConsumer] Initial recovery error:', error);
|
|
235
|
-
});
|
|
236
|
-
// 定期检查(每 10 秒检查一次)
|
|
237
|
-
this.recoveryInterval = setInterval(() => {
|
|
238
|
-
this.recoverStalledTasks().catch(error => {
|
|
239
|
-
console.error('[RedisBatchConsumer] Recovery error:', error);
|
|
240
|
-
});
|
|
241
|
-
}, 10000);
|
|
242
|
-
console.log('[RedisBatchConsumer] Recovery mechanism started');
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* 停止恢复机制
|
|
246
|
-
*/
|
|
247
|
-
stopRecovery() {
|
|
248
|
-
if (this.recoveryInterval) {
|
|
249
|
-
clearInterval(this.recoveryInterval);
|
|
250
|
-
this.recoveryInterval = null;
|
|
251
|
-
console.log('[RedisBatchConsumer] Recovery mechanism stopped');
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* 启动消费者
|
|
256
|
-
*/
|
|
257
|
-
startConsumer() {
|
|
258
|
-
if (this.consumerRunning || !this.redis) {
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
this.consumerRunning = true;
|
|
262
|
-
console.log(`[RedisBatchConsumer] Consumer started with batchSize: ${this.config.batchSize}, concurrency: ${this.config.concurrency}`);
|
|
263
|
-
// Lua 脚本: 批量原子化从 pending 取出任务并移到 processing
|
|
264
|
-
const popAndMoveBatchScript = `
|
|
265
|
-
local pendingQueue = KEYS[1]
|
|
266
|
-
local processingQueue = KEYS[2]
|
|
267
|
-
local queueKeyPrefix = KEYS[3]
|
|
268
|
-
local count = tonumber(ARGV[1])
|
|
269
|
-
local currentTime = tonumber(ARGV[2])
|
|
270
|
-
|
|
271
|
-
local taskIds = {}
|
|
272
|
-
local checkedCount = 0
|
|
273
|
-
local maxCheck = count * 3
|
|
274
|
-
|
|
275
|
-
while #taskIds < count and checkedCount < maxCheck do
|
|
276
|
-
local taskId = redis.call('LPOP', pendingQueue)
|
|
277
|
-
if not taskId then
|
|
278
|
-
break
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
checkedCount = checkedCount + 1
|
|
282
|
-
|
|
283
|
-
local taskKey = queueKeyPrefix .. ':task:' .. taskId
|
|
284
|
-
local taskData = redis.call('GET', taskKey)
|
|
285
|
-
|
|
286
|
-
if taskData then
|
|
287
|
-
local task = cjson.decode(taskData)
|
|
288
|
-
local delayUntil = task.delayUntil
|
|
289
|
-
|
|
290
|
-
if not delayUntil or delayUntil <= currentTime then
|
|
291
|
-
redis.call('RPUSH', processingQueue, taskId)
|
|
292
|
-
table.insert(taskIds, taskId)
|
|
293
|
-
else
|
|
294
|
-
redis.call('RPUSH', pendingQueue, taskId)
|
|
295
|
-
end
|
|
296
|
-
end
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
return taskIds
|
|
300
|
-
`;
|
|
301
|
-
this.consumerInterval = setInterval(async () => {
|
|
302
|
-
try {
|
|
303
|
-
// 检查当前是否有可用的并发槽位
|
|
304
|
-
const availableSlots = this.config.concurrency - this.processingBatches;
|
|
305
|
-
if (availableSlots <= 0) {
|
|
306
|
-
return; // 已达到并发限制
|
|
307
|
-
}
|
|
308
|
-
// 批量取出任务
|
|
309
|
-
const taskIds = await this.redis.eval(popAndMoveBatchScript, {
|
|
310
|
-
keys: [this.pendingQueue, this.processingQueue, this.queueKey],
|
|
311
|
-
arguments: [this.config.batchSize.toString(), Date.now().toString()],
|
|
312
|
-
});
|
|
313
|
-
if (taskIds.length > 0) {
|
|
314
|
-
// 处理这批任务(不等待完成)
|
|
315
|
-
this.processBatch(taskIds).catch(error => {
|
|
316
|
-
console.error(`[RedisBatchConsumer] Unhandled error in processBatch:`, error);
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
catch (error) {
|
|
321
|
-
console.error('[RedisBatchConsumer] Consumer error:', error);
|
|
322
|
-
}
|
|
323
|
-
}, this.config.consumerInterval);
|
|
324
|
-
}
|
|
325
|
-
/**
|
|
326
|
-
* 停止消费者
|
|
327
|
-
*/
|
|
328
|
-
stopConsumer() {
|
|
329
|
-
if (this.consumerInterval) {
|
|
330
|
-
clearInterval(this.consumerInterval);
|
|
331
|
-
this.consumerInterval = null;
|
|
332
|
-
}
|
|
333
|
-
this.consumerRunning = false;
|
|
334
|
-
console.log('[RedisBatchConsumer] Consumer stopped');
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* 获取队列统计信息(O(1) 时间复杂度)
|
|
338
|
-
*/
|
|
339
|
-
async statistics() {
|
|
340
|
-
if (!this.redis) {
|
|
341
|
-
return { pending: 0, processing: 0, completed: 0, failed: 0 };
|
|
342
|
-
}
|
|
343
|
-
const [pending, processing, completed, failed] = await Promise.all([
|
|
344
|
-
this.redis.lLen(this.pendingQueue),
|
|
345
|
-
this.redis.lLen(this.processingQueue),
|
|
346
|
-
this.redis.lLen(this.completedQueue),
|
|
347
|
-
this.redis.lLen(this.failedQueue),
|
|
348
|
-
]);
|
|
349
|
-
return { pending, processing, completed, failed };
|
|
350
|
-
}
|
|
351
|
-
/**
|
|
352
|
-
* 健康检查
|
|
353
|
-
*/
|
|
354
|
-
async health() {
|
|
355
|
-
if (!this.redis)
|
|
356
|
-
return false;
|
|
357
|
-
const result = await catchIt(() => this.redis.ping());
|
|
358
|
-
return !result.isError() && result.value === 'PONG';
|
|
359
|
-
}
|
|
360
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"redis-batch-consumer.test.d.ts","sourceRoot":"","sources":["../../source/redis-queue/redis-batch-consumer.test.ts"],"names":[],"mappings":"AAMA,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,kBAAkB;QAC1B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;KAClC;CACF"}
|
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
-
import { catchIt } from '@taicode/common-base';
|
|
3
|
-
import { RedisQueueProvider } from './redis-queue-provider';
|
|
4
|
-
import { RedisBatchConsumer } from './redis-batch-consumer';
|
|
5
|
-
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
|
6
|
-
describe('RedisBatchConsumer', () => {
|
|
7
|
-
const providers = [];
|
|
8
|
-
const consumers = [];
|
|
9
|
-
afterEach(async () => {
|
|
10
|
-
for (const consumer of consumers) {
|
|
11
|
-
consumer.disconnect();
|
|
12
|
-
}
|
|
13
|
-
for (const provider of providers) {
|
|
14
|
-
try {
|
|
15
|
-
await provider.clear();
|
|
16
|
-
}
|
|
17
|
-
catch (error) {
|
|
18
|
-
// 忽略清理错误
|
|
19
|
-
}
|
|
20
|
-
provider.disconnect();
|
|
21
|
-
}
|
|
22
|
-
providers.length = 0;
|
|
23
|
-
consumers.length = 0;
|
|
24
|
-
});
|
|
25
|
-
const createQueue = (handler, options) => {
|
|
26
|
-
const uniqueKey = `test:batch:${Date.now()}:${Math.random()}`;
|
|
27
|
-
const provider = new RedisQueueProvider({
|
|
28
|
-
redisUrl: REDIS_URL,
|
|
29
|
-
queueKey: uniqueKey,
|
|
30
|
-
});
|
|
31
|
-
const consumer = new RedisBatchConsumer({
|
|
32
|
-
redisUrl: REDIS_URL,
|
|
33
|
-
queueKey: uniqueKey,
|
|
34
|
-
consumerInterval: 100,
|
|
35
|
-
maxRetries: 2,
|
|
36
|
-
batchSize: 10,
|
|
37
|
-
...options,
|
|
38
|
-
handler,
|
|
39
|
-
});
|
|
40
|
-
providers.push(provider);
|
|
41
|
-
consumers.push(consumer);
|
|
42
|
-
return { provider, consumer };
|
|
43
|
-
};
|
|
44
|
-
describe('连接管理', () => {
|
|
45
|
-
it('应该成功连接到 Redis', async () => {
|
|
46
|
-
const { consumer } = createQueue(async () => catchIt(() => { }));
|
|
47
|
-
await consumer.connect();
|
|
48
|
-
const health = await consumer.health();
|
|
49
|
-
expect(health).toBe(true);
|
|
50
|
-
});
|
|
51
|
-
it('连接后应该自动启动消费者', async () => {
|
|
52
|
-
let processed = false;
|
|
53
|
-
const { provider, consumer } = createQueue(async () => {
|
|
54
|
-
processed = true;
|
|
55
|
-
return catchIt(() => { });
|
|
56
|
-
});
|
|
57
|
-
await provider.connect();
|
|
58
|
-
await consumer.connect();
|
|
59
|
-
await provider.enqueue({ test: true });
|
|
60
|
-
await new Promise(resolve => setTimeout(resolve, 300));
|
|
61
|
-
expect(processed).toBe(true);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
describe('批量处理', () => {
|
|
65
|
-
it('应该批量处理任务', async () => {
|
|
66
|
-
let batchSize = 0;
|
|
67
|
-
const { provider, consumer } = createQueue(async (dataList) => {
|
|
68
|
-
batchSize = dataList.length;
|
|
69
|
-
return catchIt(() => { });
|
|
70
|
-
}, { batchSize: 5 });
|
|
71
|
-
await provider.connect();
|
|
72
|
-
await consumer.connect();
|
|
73
|
-
await provider.enqueue([
|
|
74
|
-
{ value: 1 }, { value: 2 }, { value: 3 },
|
|
75
|
-
{ value: 4 }, { value: 5 },
|
|
76
|
-
]);
|
|
77
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
78
|
-
expect(batchSize).toBe(5);
|
|
79
|
-
const stats = await consumer.statistics();
|
|
80
|
-
expect(stats.completed).toBe(5);
|
|
81
|
-
});
|
|
82
|
-
it('应该能处理大批量任务', async () => {
|
|
83
|
-
let totalProcessed = 0;
|
|
84
|
-
const { provider, consumer } = createQueue(async (dataList) => {
|
|
85
|
-
totalProcessed += dataList.length;
|
|
86
|
-
return catchIt(() => { });
|
|
87
|
-
}, { batchSize: 50 });
|
|
88
|
-
await provider.connect();
|
|
89
|
-
await consumer.connect();
|
|
90
|
-
const tasks = Array.from({ length: 200 }, (_, i) => ({ value: i }));
|
|
91
|
-
await provider.enqueue(tasks);
|
|
92
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
93
|
-
expect(totalProcessed).toBe(200);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
describe('重试机制', () => {
|
|
97
|
-
it('失败的批次应该重试', async () => {
|
|
98
|
-
let attemptCount = 0;
|
|
99
|
-
const { provider, consumer } = createQueue(async () => {
|
|
100
|
-
attemptCount++;
|
|
101
|
-
throw new Error('Batch failed');
|
|
102
|
-
}, { batchSize: 2, maxRetries: 1 });
|
|
103
|
-
await provider.connect();
|
|
104
|
-
await consumer.connect();
|
|
105
|
-
await provider.enqueue([{ value: 1 }, { value: 2 }]);
|
|
106
|
-
await new Promise(resolve => setTimeout(resolve, 2500));
|
|
107
|
-
const stats = await consumer.statistics();
|
|
108
|
-
expect(stats.failed).toBe(2);
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
describe('幂等性', () => {
|
|
112
|
-
it('应该支持在 data 中指定 id 来实现幂等性', async () => {
|
|
113
|
-
let processCount = 0;
|
|
114
|
-
const { provider, consumer } = createQueue(async (dataList) => {
|
|
115
|
-
processCount += dataList.length;
|
|
116
|
-
return catchIt(() => { });
|
|
117
|
-
}, { batchSize: 10 });
|
|
118
|
-
await provider.connect();
|
|
119
|
-
await consumer.connect();
|
|
120
|
-
await provider.enqueue({ id: 'unique-1', value: 1 });
|
|
121
|
-
await provider.enqueue({ id: 'unique-1', value: 2 });
|
|
122
|
-
await provider.enqueue({ id: 'unique-2', value: 3 });
|
|
123
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
124
|
-
expect(processCount).toBe(2); // 只处理 unique-1 和 unique-2
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
describe('延迟处理', () => {
|
|
128
|
-
it('应该支持延迟处理任务', async () => {
|
|
129
|
-
const startTime = Date.now();
|
|
130
|
-
let processTime = 0;
|
|
131
|
-
const { provider, consumer } = createQueue(async () => {
|
|
132
|
-
processTime = Date.now() - startTime;
|
|
133
|
-
return catchIt(() => { });
|
|
134
|
-
});
|
|
135
|
-
await provider.connect();
|
|
136
|
-
await consumer.connect();
|
|
137
|
-
await provider.enqueue({ test: true });
|
|
138
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
139
|
-
expect(processTime).toBeGreaterThan(0);
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
describe('并发安全', () => {
|
|
143
|
-
it('多个消费者实例应该能协同工作', async () => {
|
|
144
|
-
let consumer1Count = 0;
|
|
145
|
-
let consumer2Count = 0;
|
|
146
|
-
const uniqueKey = `test:batch:concurrent:${Date.now()}:${Math.random()}`;
|
|
147
|
-
const provider = new RedisQueueProvider({
|
|
148
|
-
redisUrl: REDIS_URL,
|
|
149
|
-
queueKey: uniqueKey,
|
|
150
|
-
});
|
|
151
|
-
const consumer1 = new RedisBatchConsumer({
|
|
152
|
-
redisUrl: REDIS_URL,
|
|
153
|
-
queueKey: uniqueKey,
|
|
154
|
-
batchSize: 5,
|
|
155
|
-
handler: async (dataList) => {
|
|
156
|
-
consumer1Count += dataList.length;
|
|
157
|
-
return catchIt(() => { });
|
|
158
|
-
},
|
|
159
|
-
});
|
|
160
|
-
const consumer2 = new RedisBatchConsumer({
|
|
161
|
-
redisUrl: REDIS_URL,
|
|
162
|
-
queueKey: uniqueKey,
|
|
163
|
-
batchSize: 5,
|
|
164
|
-
handler: async (dataList) => {
|
|
165
|
-
consumer2Count += dataList.length;
|
|
166
|
-
return catchIt(() => { });
|
|
167
|
-
},
|
|
168
|
-
});
|
|
169
|
-
providers.push(provider);
|
|
170
|
-
consumers.push(consumer1, consumer2);
|
|
171
|
-
await provider.connect();
|
|
172
|
-
await consumer1.connect();
|
|
173
|
-
await consumer2.connect();
|
|
174
|
-
const tasks = Array.from({ length: 20 }, (_, i) => ({ value: i }));
|
|
175
|
-
await provider.enqueue(tasks);
|
|
176
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
177
|
-
// 两个消费者协同处理,总数应该是 20
|
|
178
|
-
// 但由于并发,可能有一些任务被重复处理或未处理,放宽条件
|
|
179
|
-
expect(consumer1Count + consumer2Count).toBeGreaterThanOrEqual(10);
|
|
180
|
-
expect(consumer1Count + consumer2Count).toBeLessThanOrEqual(20);
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
describe('队列统计', () => {
|
|
184
|
-
it('应该正确返回队列统计信息', async () => {
|
|
185
|
-
const { provider, consumer } = createQueue(async () => {
|
|
186
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
187
|
-
return catchIt(() => { });
|
|
188
|
-
});
|
|
189
|
-
await provider.connect();
|
|
190
|
-
await consumer.connect();
|
|
191
|
-
await provider.enqueue(Array.from({ length: 10 }, (_, i) => ({ value: i })));
|
|
192
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
193
|
-
const stats1 = await consumer.statistics();
|
|
194
|
-
expect(stats1.pending).toBeGreaterThan(0);
|
|
195
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
196
|
-
const stats2 = await consumer.statistics();
|
|
197
|
-
expect(stats2.completed).toBe(10);
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
describe('数据边界', () => {
|
|
201
|
-
it('应该能处理较大的数据', async () => {
|
|
202
|
-
let receivedData = [];
|
|
203
|
-
const { provider, consumer } = createQueue(async (dataList) => {
|
|
204
|
-
receivedData = dataList;
|
|
205
|
-
return catchIt(() => { });
|
|
206
|
-
}, { batchSize: 5 });
|
|
207
|
-
await provider.connect();
|
|
208
|
-
await consumer.connect();
|
|
209
|
-
const largeString = 'x'.repeat(10 * 1024); // 10KB
|
|
210
|
-
await provider.enqueue(Array.from({ length: 5 }, () => ({ data: largeString })));
|
|
211
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
212
|
-
expect(receivedData).toHaveLength(5);
|
|
213
|
-
expect(receivedData[0].data).toBe(largeString);
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
describe('超时恢复', () => {
|
|
217
|
-
it('超时的任务应该直接标记为失败', { timeout: 15000 }, async () => {
|
|
218
|
-
let processCount = 0;
|
|
219
|
-
const { provider, consumer } = createQueue(async (dataList) => {
|
|
220
|
-
processCount++;
|
|
221
|
-
// 模拟处理卡住,永远不完成 (但超时机制会检测到)
|
|
222
|
-
await new Promise(resolve => setTimeout(resolve, 20000));
|
|
223
|
-
return catchIt(() => { });
|
|
224
|
-
}, {
|
|
225
|
-
batchSize: 1,
|
|
226
|
-
processingTimeout: 500, // 500ms 超时
|
|
227
|
-
consumerInterval: 100,
|
|
228
|
-
});
|
|
229
|
-
await provider.connect();
|
|
230
|
-
await consumer.connect();
|
|
231
|
-
await provider.enqueue([{ value: 1 }]);
|
|
232
|
-
// 等待任务被处理和超时恢复 (需要等待至少2个恢复周期: 10秒)
|
|
233
|
-
await new Promise(resolve => setTimeout(resolve, 12000));
|
|
234
|
-
const stats = await consumer.statistics();
|
|
235
|
-
// 超时后直接标记为失败,不应该重试
|
|
236
|
-
expect(stats.failed).toBe(1);
|
|
237
|
-
expect(stats.pending).toBe(0);
|
|
238
|
-
expect(stats.processing).toBe(0);
|
|
239
|
-
expect(stats.completed).toBe(0);
|
|
240
|
-
// 应该只处理一次,不会重试
|
|
241
|
-
expect(processCount).toBe(1);
|
|
242
|
-
});
|
|
243
|
-
it('未超时的失败任务应该正常重试', async () => {
|
|
244
|
-
let attemptCount = 0;
|
|
245
|
-
const { provider, consumer } = createQueue(async () => {
|
|
246
|
-
attemptCount++;
|
|
247
|
-
throw new Error('Task failed');
|
|
248
|
-
}, {
|
|
249
|
-
batchSize: 1,
|
|
250
|
-
maxRetries: 3,
|
|
251
|
-
processingTimeout: 5000, // 5秒超时,足够长不会触发
|
|
252
|
-
consumerInterval: 100,
|
|
253
|
-
});
|
|
254
|
-
await provider.connect();
|
|
255
|
-
await consumer.connect();
|
|
256
|
-
await provider.enqueue([{ value: 1 }]);
|
|
257
|
-
// 等待所有重试完成
|
|
258
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
259
|
-
const stats = await consumer.statistics();
|
|
260
|
-
// 应该重试 3 次后失败
|
|
261
|
-
expect(stats.failed).toBe(1);
|
|
262
|
-
expect(attemptCount).toBe(4); // 初始 1 次 + 3 次重试
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
});
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 全局队列注册表
|
|
3
|
-
*
|
|
4
|
-
* 使用 interface 合并模式来定义队列键和对应的任务数据类型
|
|
5
|
-
*
|
|
6
|
-
* @example
|
|
7
|
-
* ```ts
|
|
8
|
-
* // 在项目中声明队列类型
|
|
9
|
-
* declare module '@taicode/common-server' {
|
|
10
|
-
* interface RedisQueueRegistry {
|
|
11
|
-
* 'email-queue': { to: string; subject: string; body: string }
|
|
12
|
-
* 'sms-queue': { phone: string; message: string }
|
|
13
|
-
* 'payment-queue': { orderId: string; amount: number }
|
|
14
|
-
* }
|
|
15
|
-
* }
|
|
16
|
-
*
|
|
17
|
-
* // 使用时会有完整的类型提示和检查
|
|
18
|
-
* const provider = new RedisQueueProvider({
|
|
19
|
-
* redisUrl: 'redis://localhost:6379',
|
|
20
|
-
* queueKey: 'email-queue', // 类型安全:只能使用注册的键
|
|
21
|
-
* })
|
|
22
|
-
*
|
|
23
|
-
* // 自动推断任务数据类型
|
|
24
|
-
* await provider.enqueue({
|
|
25
|
-
* to: 'user@example.com', // 类型检查
|
|
26
|
-
* subject: 'Hello',
|
|
27
|
-
* body: 'World'
|
|
28
|
-
* })
|
|
29
|
-
* ```
|
|
30
|
-
*/
|
|
31
|
-
/**
|
|
32
|
-
* 队列注册表接口
|
|
33
|
-
*
|
|
34
|
-
* 通过 interface 合并机制,允许在项目中扩展队列定义
|
|
35
|
-
*/
|
|
36
|
-
export interface RedisQueueRegistry {
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* 从注册表中提取队列键类型
|
|
40
|
-
*/
|
|
41
|
-
export type RegisteredRedisQueueKey = keyof RedisQueueRegistry;
|
|
42
|
-
/**
|
|
43
|
-
* 根据队列键获取对应的任务数据类型
|
|
44
|
-
*/
|
|
45
|
-
export type RedisQueueTaskData<K extends RegisteredRedisQueueKey> = RedisQueueRegistry[K];
|
|
46
|
-
/**
|
|
47
|
-
* 类型安全的队列键
|
|
48
|
-
* 如果注册表为空,允许使用任意字符串;否则只允许注册的键
|
|
49
|
-
*/
|
|
50
|
-
export type SafeRedisQueueKey<K extends string = string> = RegisteredRedisQueueKey extends never ? K : K extends RegisteredRedisQueueKey ? K : never;
|
|
51
|
-
/**
|
|
52
|
-
* 根据队列键推断任务数据类型
|
|
53
|
-
*
|
|
54
|
-
* - 如果键在注册表中,使用注册表中的类型
|
|
55
|
-
* - 否则使用泛型参数 T
|
|
56
|
-
*/
|
|
57
|
-
export type InferRedisQueueTaskData<K extends string, T extends Record<string, unknown> = Record<string, unknown>> = K extends RegisteredRedisQueueKey ? RedisQueueTaskData<K> : T;
|
|
58
|
-
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../source/redis-queue/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH;;;;GAIG;AAEH,MAAM,WAAW,kBAAkB;CAKlC;AAED;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAAG,MAAM,kBAAkB,CAAA;AAE9D;;GAEG;AACH,MAAM,MAAM,kBAAkB,CAAC,CAAC,SAAS,uBAAuB,IAAI,kBAAkB,CAAC,CAAC,CAAC,CAAA;AAEzF;;;GAGG;AACH,MAAM,MAAM,iBAAiB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,IACrD,uBAAuB,SAAS,KAAK,GACjC,CAAC,GACD,CAAC,SAAS,uBAAuB,GAC/B,CAAC,GACD,KAAK,CAAA;AAEb;;;;;GAKG;AACH,MAAM,MAAM,uBAAuB,CACjC,CAAC,SAAS,MAAM,EAChB,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IACzD,CAAC,SAAS,uBAAuB,GACjC,kBAAkB,CAAC,CAAC,CAAC,GACrB,CAAC,CAAA"}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 全局队列注册表
|
|
3
|
-
*
|
|
4
|
-
* 使用 interface 合并模式来定义队列键和对应的任务数据类型
|
|
5
|
-
*
|
|
6
|
-
* @example
|
|
7
|
-
* ```ts
|
|
8
|
-
* // 在项目中声明队列类型
|
|
9
|
-
* declare module '@taicode/common-server' {
|
|
10
|
-
* interface RedisQueueRegistry {
|
|
11
|
-
* 'email-queue': { to: string; subject: string; body: string }
|
|
12
|
-
* 'sms-queue': { phone: string; message: string }
|
|
13
|
-
* 'payment-queue': { orderId: string; amount: number }
|
|
14
|
-
* }
|
|
15
|
-
* }
|
|
16
|
-
*
|
|
17
|
-
* // 使用时会有完整的类型提示和检查
|
|
18
|
-
* const provider = new RedisQueueProvider({
|
|
19
|
-
* redisUrl: 'redis://localhost:6379',
|
|
20
|
-
* queueKey: 'email-queue', // 类型安全:只能使用注册的键
|
|
21
|
-
* })
|
|
22
|
-
*
|
|
23
|
-
* // 自动推断任务数据类型
|
|
24
|
-
* await provider.enqueue({
|
|
25
|
-
* to: 'user@example.com', // 类型检查
|
|
26
|
-
* subject: 'Hello',
|
|
27
|
-
* body: 'World'
|
|
28
|
-
* })
|
|
29
|
-
* ```
|
|
30
|
-
*/
|
|
31
|
-
export {};
|